Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a0b0c808 | ||
|
|
dc11cb6a72 | ||
|
|
4d86db868c | ||
|
|
9b6aa8a6e8 | ||
|
|
9249a1e0a1 | ||
|
|
b7dfda1f99 | ||
|
|
93c81d0fa6 | ||
|
|
e9b55bb0e1 | ||
|
|
426d60b692 | ||
|
|
60db82e459 | ||
|
|
c10866bffd | ||
|
|
c127a1d065 | ||
|
|
cd5405fe62 | ||
|
|
98fd2fba5a | ||
|
|
5c843dc76f | ||
|
|
37832b72da | ||
|
|
aab39f66af | ||
|
|
365a3898b9 | ||
|
|
2bbd2dc7c8 | ||
|
|
93af230406 | ||
|
|
c98e46437e | ||
|
|
3bae89a203 | ||
|
|
123c0dbb7b | ||
|
|
57e1f63bcf | ||
|
|
bea4875b05 | ||
|
|
343f647770 | ||
|
|
7184d998e6 | ||
|
|
189bde2008 | ||
|
|
a090527037 | ||
|
|
1595327a80 | ||
|
|
b4b6158f2d | ||
|
|
0e56de3c56 | ||
|
|
f9b8611519 | ||
|
|
84e5119c41 | ||
|
|
67a28e0037 | ||
|
|
fb6c9d4d61 |
@@ -0,0 +1,157 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build & publish plugin zip
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build plugin zip
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
OUT="dist/projects-portfolio-v${VERSION}.zip"
|
||||||
|
|
||||||
|
rm -rf dist
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
# Use Python's stdlib zipfile module — guaranteed to be on ubuntu-latest.
|
||||||
|
python3 - "$OUT" <<'PYEOF'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
out_path = sys.argv[1]
|
||||||
|
allowed = [
|
||||||
|
"projects-portfolio.php",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
"admin",
|
||||||
|
"assets",
|
||||||
|
"includes",
|
||||||
|
"languages",
|
||||||
|
"templates",
|
||||||
|
]
|
||||||
|
|
||||||
|
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for entry in allowed:
|
||||||
|
if os.path.isdir(entry):
|
||||||
|
for root, _, files in os.walk(entry):
|
||||||
|
for name in files:
|
||||||
|
abs_path = os.path.join(root, name)
|
||||||
|
arcname = os.path.join("projects-portfolio", abs_path)
|
||||||
|
zf.write(abs_path, arcname)
|
||||||
|
elif os.path.isfile(entry):
|
||||||
|
zf.write(entry, os.path.join("projects-portfolio", entry))
|
||||||
|
else:
|
||||||
|
sys.exit(f"Allowlisted path missing: {entry}")
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
echo "Built $OUT"
|
||||||
|
python3 -c "import zipfile; zf=zipfile.ZipFile('$OUT'); print('\n'.join(zf.namelist()))"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: projects-portfolio-${{ github.ref_name }}
|
||||||
|
path: dist/projects-portfolio-*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
GH_API_URL: ${{ github.api_url }}
|
||||||
|
GH_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
zip_path=( dist/projects-portfolio-*.zip )
|
||||||
|
zip_path="${zip_path[0]}"
|
||||||
|
asset_name=$(basename "$zip_path")
|
||||||
|
|
||||||
|
if [[ "$GH_API_URL" == "https://api.github.com" ]]; then
|
||||||
|
# ---- GitHub.com path ----
|
||||||
|
if [ -z "${GITHUB_TOKEN:-}" ]; then
|
||||||
|
echo "::error::GITHUB_TOKEN is not available on this runner. Check repo permissions." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
payload=$(python3 scripts/release-helper.py build-payload --github)
|
||||||
|
# Look up existing release by tag (idempotent on re-run).
|
||||||
|
release_json=$(curl -fsS \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"${GH_API_URL}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$release_json" ] && echo "$release_json" | python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.stdin.read()).get("id") else 1)' 2>/dev/null; then
|
||||||
|
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
|
||||||
|
upload_url=$(echo "$release_json" | python3 scripts/release-helper.py extract-upload-url)
|
||||||
|
echo "Reusing existing GitHub release id=$release_id for tag $TAG"
|
||||||
|
else
|
||||||
|
release_json=$(curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"${GH_API_URL}/repos/${REPO}/releases")
|
||||||
|
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
|
||||||
|
upload_url=$(echo "$release_json" | python3 scripts/release-helper.py extract-upload-url)
|
||||||
|
echo "Created GitHub release id=$release_id"
|
||||||
|
fi
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary "@${zip_path}" \
|
||||||
|
"${upload_url}?name=${asset_name}"
|
||||||
|
echo "Uploaded GitHub asset: ${asset_name}"
|
||||||
|
else
|
||||||
|
# ---- Gitea path ----
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
echo "::error::GITEA_TOKEN secret is not set. Create a personal access token in Gitea with 'write:repository' scope and add it as a repository secret named GITEA_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${GH_API_URL:-}" ] || [[ "$GH_API_URL" == "https://git.keithsolomon.net"* ]]; then
|
||||||
|
API="https://git.keithsolomon.net/api/v1"
|
||||||
|
else
|
||||||
|
API="${GH_API_URL%/}/api/v1"
|
||||||
|
fi
|
||||||
|
payload=$(python3 scripts/release-helper.py build-payload)
|
||||||
|
# Look up existing release by tag (idempotent on re-run).
|
||||||
|
release_json=$(curl -fsS \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$release_json" ] && echo "$release_json" | python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.stdin.read()).get("id") else 1)' 2>/dev/null; then
|
||||||
|
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
|
||||||
|
echo "Reusing existing Gitea release id=$release_id for tag $TAG"
|
||||||
|
else
|
||||||
|
release_json=$(curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"${API}/repos/${REPO}/releases")
|
||||||
|
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
|
||||||
|
echo "Created Gitea release id=$release_id"
|
||||||
|
fi
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary "@${zip_path}" \
|
||||||
|
"${API}/repos/${REPO}/releases/${release_id}/assets?name=${asset_name}" \
|
||||||
|
|| echo "::warning::Asset upload failed (asset may already exist); continuing."
|
||||||
|
echo "Gitea asset step complete: ${asset_name}"
|
||||||
|
fi
|
||||||
@@ -5,3 +5,5 @@ notes/
|
|||||||
vendor/
|
vendor/
|
||||||
phpunit.xml
|
phpunit.xml
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
# But DO track the vendor/ that ships with the bundled plugin-update-checker library.
|
||||||
|
!/includes/plugin-update-checker/vendor/
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="Coding Style Checks">
|
||||||
|
<description>Coding Style Checks.</description>
|
||||||
|
<ini name="error_reporting" value="E_ALL & ~E_DEPRECATED" />
|
||||||
|
|
||||||
|
<arg value="sp"/>
|
||||||
|
<arg name="colors"/>
|
||||||
|
<arg name="extensions" value="php,html"/>
|
||||||
|
<arg name="parallel" value="2048"/>
|
||||||
|
|
||||||
|
<exclude-pattern>vendor/</exclude-pattern>
|
||||||
|
<exclude-pattern>node_modules/</exclude-pattern>
|
||||||
|
|
||||||
|
<rule ref="WordPress">
|
||||||
|
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
|
||||||
|
<exclude name="Generic.Functions.CallTimePassByReference"/>
|
||||||
|
<exclude name="Universal.WhiteSpace.PrecisionAlignment.Found"/>
|
||||||
|
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralText"/>
|
||||||
|
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralDomain"/>
|
||||||
|
<exclude name="WordPress.WP.I18n.NoEmptyStrings"/>
|
||||||
|
<exclude name="WordPress.WP.EnqueuedResourceParameters.MissingVersion"/>
|
||||||
|
<exclude name="WordPress.PHP.YodaConditions.NotYoda"/>
|
||||||
|
<exclude name="WordPress.PHP.NoSilencedErrors.Discouraged"/>
|
||||||
|
<exclude name="WordPress.Files.FileName.NotHyphenatedLowercase"/>
|
||||||
|
<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidHookName.NotLowercase"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidHookName.UseUnderscores"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid"/>
|
||||||
|
<exclude name="WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid"/>
|
||||||
|
<exclude name="WordPress.DB.SlowDBQuery.slow_db_query_tax_query"/>
|
||||||
|
<exclude name="WordPress.DB.SlowDBQuery.slow_db_query_meta_query"/>
|
||||||
|
<exclude name="WordPress.Security.NonceVerification.Recommended"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.MissingPackageTag"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.Missing"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.WrongStyle"/>
|
||||||
|
<exclude name="Squiz.Commenting.InlineComment.InvalidEndChar"/>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<rule ref="Internal.NoCodeFound">
|
||||||
|
<severity>0</severity>
|
||||||
|
</rule>
|
||||||
|
</ruleset>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# Projects Portfolio — Design
|
||||||
|
|
||||||
|
A developer-first showcase directory for WordPress plugins, themes, and
|
||||||
|
patterns. Each "Project" post links to a GitHub or Gitea repository; the
|
||||||
|
plugin pulls repo metadata (version, stars, forks, license, language,
|
||||||
|
release ZIP) on demand and renders a per-project detail page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
- A `projects` custom post type and `project-type` taxonomy — a WordPress-native showcase.
|
||||||
|
- A metabox on each project that captures a Provider (GitHub or Gitea), the repo URL, and (for Gitea) a per-project base URL override.
|
||||||
|
- A normalized data model so templates and the REST endpoint never branch on the underlying host.
|
||||||
|
- A `/download/{id}/` endpoint that redirects to the release ZIP on the upstream host and increments a local counter.
|
||||||
|
- Gitea parity with GitHub for the common metadata fields (stars, forks, issues, license, language, version) — with provider-agnostic display.
|
||||||
|
- A settings page that exposes a Gitea base URL + token, template toggle fields, and archive toggle fields.
|
||||||
|
- Works on both Gitea Actions and GitHub Actions runners (single workflow file with host-branching).
|
||||||
|
|
||||||
|
## 2. Non-goals
|
||||||
|
|
||||||
|
- Provider-agnostic **write** operations (no plugin editing Gitea or GitHub on the user's behalf).
|
||||||
|
- Multi-repo rendering on a single Project post (one project = one repo).
|
||||||
|
- Cross-provider migration tools — adding a new project is per-provider manual.
|
||||||
|
- WordPress.org plugin distribution (the Gitea release zip is the deliverable).
|
||||||
|
- Browsing Gitea organizations or selecting from a list — the repo URL is user-provided.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Layered model
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Templates + REST API + Download Redirect │
|
||||||
|
│ (consume the interface, no provider logic) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Provider factory: │
|
||||||
|
│ projects_portfolio_get_provider($post_id)│
|
||||||
|
│ reads post meta, returns adapter │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
▼ ▼
|
||||||
|
GitHub_Provider Gitea_Provider
|
||||||
|
(implements (implements
|
||||||
|
Repository_Provider) Repository_Provider)
|
||||||
|
│ │
|
||||||
|
└────────────┬────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ WP HTTP API + transient cache │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
projects-portfolio/
|
||||||
|
├── projects-portfolio.php Plugin bootstrap; CPT/taxonomy wiring; rewrite
|
||||||
|
│ rules; download redirect; REST registration;
|
||||||
|
│ admin columns; social-share buttons
|
||||||
|
├── composer.json / composer.lock Dev-only deps (PHPUnit, Brain\Monkey,
|
||||||
|
│ yoast/phpunit-polyfills)
|
||||||
|
├── phpunit.xml.dist PHPUnit configuration
|
||||||
|
├── .phpcs.xml PHPCS coding-style ruleset
|
||||||
|
├── README.md End-user documentation
|
||||||
|
├── DESIGN.md This file
|
||||||
|
│
|
||||||
|
├── admin/
|
||||||
|
│ ├── cpt-taxonomy.php `projects` CPT + `project-type` taxonomy
|
||||||
|
│ ├── metabox.php Repository Provider / URL / Gitea base metabox
|
||||||
|
│ ├── admin-settings.php Settings page (General + Gitea + Templates + Archives)
|
||||||
|
│ └── rest-api.php REST route registration (if separated)
|
||||||
|
│
|
||||||
|
├── includes/
|
||||||
|
│ ├── helper-functions.php Provider-aware wrappers, settings aggregator
|
||||||
|
│ ├── plugin-update-checker/ Bundled PUC v5.7 (self-update notifications)
|
||||||
|
│ └── providers/
|
||||||
|
│ ├── interface-repository-provider.php Contract (7 methods)
|
||||||
|
│ ├── class-github-provider.php GitHub adapter (preserves v1.0 behavior)
|
||||||
|
│ ├── class-gitea-provider.php Gitea adapter (self-hosted, configurable)
|
||||||
|
│ └── class-provider-factory.php projects_portfolio_get_provider()
|
||||||
|
│
|
||||||
|
├── templates/
|
||||||
|
│ ├── single-projects.php Per-project detail template
|
||||||
|
│ ├── archive-projects.php CPT archive
|
||||||
|
│ └── taxonomy-project-type.php Taxonomy archive
|
||||||
|
│
|
||||||
|
├── assets/
|
||||||
|
│ ├── css/style.css Front-end styles
|
||||||
|
│ ├── css/admin-styles.css Admin styles
|
||||||
|
│ ├── js/buttons.js (Legacy — retained for compat, unused)
|
||||||
|
│ └── icons/ Lucide brand-share SVG icons (Facebook, X,
|
||||||
|
│ LinkedIn, Mail, Reddit, WhatsApp, Pinterest)
|
||||||
|
│
|
||||||
|
├── languages/ Translation files (.pot/.po/.mo/.l10n.php)
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ └── release-helper.py GHA workflow helper: build payload,
|
||||||
|
│ extract id, extract upload_url
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ ├── bootstrap.php Brain\Monkey bootstrap + WP function stubs
|
||||||
|
│ ├── wp-stubs.php Shared WP function stubs (brain\monkey safe)
|
||||||
|
│ ├── test-github-provider.php
|
||||||
|
│ ├── test-gitea-provider.php
|
||||||
|
│ ├── test-provider-factory.php
|
||||||
|
│ ├── test-metabox.php
|
||||||
|
│ └── test-settings.php
|
||||||
|
│
|
||||||
|
├── .github/workflows/
|
||||||
|
│ └── release.yml Builds projects-portfolio-v<version>.zip on `v*`
|
||||||
|
│ tag push; branches on host (Gitea Actions vs
|
||||||
|
│ GitHub Actions).
|
||||||
|
│
|
||||||
|
├── specs/ Design specs (Gitea-support, release-workflow)
|
||||||
|
├── plans/ Implementation plans
|
||||||
|
└── docs/superpowers/ Skill workflow artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 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; // zip URL or null
|
||||||
|
public function get_latest_version(): string; // tag name or 'Unknown'
|
||||||
|
public function get_repo_browse_url(): string; // public repo URL
|
||||||
|
public function get_owner_data( string $owner_login ): ?array; // avatar/login/html_url
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both adapters implement this contract. Templates, the REST endpoint, and the download handler never branch on provider — they only call the interface.
|
||||||
|
|
||||||
|
## 4. Per-project storage
|
||||||
|
|
||||||
|
Post meta keys (all `string` unless noted):
|
||||||
|
|
||||||
|
| Key | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `_projects_portfolio_provider` | `'github'` \| `'gitea'` | Defaults to `'github'` when missing. |
|
||||||
|
| `_projects_portfolio_repo_url` | string (URL) | Canonical repo URL. Falls back to legacy `_projects_portfolio_github_url` when empty. |
|
||||||
|
| `_projects_portfolio_gitea_base_url` | string (URL) | Per-project Gitea override. Falls back to the global option when empty. |
|
||||||
|
| `_projects_portfolio_github_url` | string (URL) | **Deprecated, read-only.** Retained for backward compatibility. Cleared when the project is saved with provider = `gitea`, or when provider = `github` and the new `repo_url` is empty. |
|
||||||
|
| `_projects_portfolio_download_count` | int | Incremented locally by the download endpoint. |
|
||||||
|
|
||||||
|
### 4.1 Lazy migration
|
||||||
|
|
||||||
|
The metabox save handler performs a one-time lazy migration:
|
||||||
|
|
||||||
|
```
|
||||||
|
on save:
|
||||||
|
if (provider meta empty AND repo_url empty AND legacy github_url non-empty):
|
||||||
|
set provider = 'github'
|
||||||
|
set repo_url = legacy github_url
|
||||||
|
if (provider meta == 'gitea'):
|
||||||
|
delete legacy github_url
|
||||||
|
if (provider meta == 'github' AND new repo_url is empty):
|
||||||
|
delete legacy github_url
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing projects that were configured before the Gitea support was added keep working without any data-migration step.
|
||||||
|
|
||||||
|
## 5. Global settings
|
||||||
|
|
||||||
|
Options (all registered via the WP Options API):
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `projects_portfolio_github_api_token` | `''` | Bearer token for authenticated GitHub requests. |
|
||||||
|
| `projects_portfolio_gitea_api_token` | `''` | Bearer token for authenticated Gitea requests. |
|
||||||
|
| `projects_portfolio_default_gitea_base_url` | `'https://codeberg.org'` | Used when a Gitea project has no per-project override. |
|
||||||
|
| `projects_portfolio_share_telemetry` | `'0'` | Reserved for future telemetry. |
|
||||||
|
| `projects_portfolio_templates_<key>` | `'0'` | One per template field (version, last_updated, license, language, downloads, forks, stargazers_count, open_issues_count, github_owner). |
|
||||||
|
| `projects_portfolio_archives_<key>` | `'0'` | One per archive field (archive_title, project_title, project_excerpt, project_buttons). |
|
||||||
|
|
||||||
|
`projects_portfolio_settings()` aggregates all of the above into a single array (cached in a function-local static for the request) and is consumed by templates.
|
||||||
|
|
||||||
|
## 6. Normalized repo data shape
|
||||||
|
|
||||||
|
Both adapters return the same shape from `get_repo_data()`. The GitHub adapter returns it natively; the Gitea adapter maps its native fields.
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'owner' => [
|
||||||
|
'avatar_url' => string,
|
||||||
|
'login' => string,
|
||||||
|
'html_url' => string,
|
||||||
|
],
|
||||||
|
'updated_at' => string, // ISO 8601
|
||||||
|
'language' => string,
|
||||||
|
'license' => [ 'name' => string ], // 'None' if unset
|
||||||
|
'stargazers_count' => int, // mapped from `stars_count` for Gitea
|
||||||
|
'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) | `license.name` ('None' if unset) |
|
||||||
|
| `owner.avatar_url` | `owner.avatar_url` |
|
||||||
|
| `owner.login` | `owner.login` |
|
||||||
|
| `owner.html_url` | `owner.html_url` (falls back to `{base}/{owner}`) |
|
||||||
|
|
||||||
|
## 7. Endpoints
|
||||||
|
|
||||||
|
### 7.1 GitHub
|
||||||
|
|
||||||
|
| Purpose | URL |
|
||||||
|
|---|---|
|
||||||
|
| Repo metadata | `https://api.github.com/repos/{owner}/{repo}` |
|
||||||
|
| Latest release | `…/{owner}/{repo}/releases/latest` |
|
||||||
|
| Owner profile | `https://api.github.com/users/{login}` |
|
||||||
|
|
||||||
|
### 7.2 Gitea
|
||||||
|
|
||||||
|
| Purpose | URL |
|
||||||
|
|---|---|
|
||||||
|
| Repo metadata | `{base}/api/v1/repos/{owner}/{repo}` |
|
||||||
|
| Latest release | `{base}/api/v1/repos/{owner}/{repo}/releases/latest` (returns a single object) |
|
||||||
|
| Owner profile | `{base}/api/v1/users/{login}` |
|
||||||
|
| Archive fallback | `{base}/{owner}/{repo}/archive/refs/tags/{tag}.zip` |
|
||||||
|
|
||||||
|
The Gitea release endpoint returns a **single release object**, not a list (unlike GitHub's). The adapter detects shape with `isset($body['tag_name'])` and treats it as a release object directly.
|
||||||
|
|
||||||
|
### 7.3 Authentication
|
||||||
|
|
||||||
|
- GitHub: header `Authorization: token <PAT>`.
|
||||||
|
- Gitea: header `Authorization: token <PAT>`. The PAT is stored in `projects_portfolio_gitea_api_token` and supplied via `GITEA_TOKEN` repo secret on the release runner.
|
||||||
|
|
||||||
|
### 7.4 Plugin-side endpoints
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `/download/{id}/` | Custom rewrite rule. Resolves to `index.php?project_download_id=<id>`. Handler at `template_redirect`: looks up the project's release URL via the provider, increments `_projects_portfolio_download_count`, then `wp_redirect()` to the upstream URL. Uses `wp_redirect()` (not `wp_safe_redirect()`) because the destination is a user-configured external host — `wp_safe_redirect()` would reject it and fall back to `admin_url()`. |
|
||||||
|
| `/wp-json/projects/v1/projects` | Lists all projects with their normalized metadata and download URL. Backward-compatible field names (`github_url`, `github_data`) plus a sibling `provider` field. |
|
||||||
|
|
||||||
|
## 8. Provider interface — method semantics
|
||||||
|
|
||||||
|
| Method | GitHub | Gitea |
|
||||||
|
|---|---|---|
|
||||||
|
| `get_id()` | `'github'` | `'gitea'` |
|
||||||
|
| `get_label()` | `'GitHub'` | `'Gitea'` |
|
||||||
|
| `get_repo_data()` | Returns the GitHub repo object directly (already matches the normalized shape). | Maps `stars_count → stargazers_count`, normalizes `license` to `{name: string}`, returns null on non-200. |
|
||||||
|
| `get_release_url()` | Iterates `assets[]` for a `.zip` entry; falls back to `zipball_url`. | Iterates the single release object's `assets[]`; falls back to `{base}/{owner}/{repo}/archive/refs/tags/{tag}.zip`. |
|
||||||
|
| `get_latest_version()` | Returns `tag_name`. | Same. |
|
||||||
|
| `get_repo_browse_url()` | Returns the user-supplied URL. | Returns `{base}/{owner_repo_path}`. |
|
||||||
|
| `get_owner_data($login)` | GET `/users/{login}` via `file_get_contents` (legacy). | GET `{base}/api/v1/users/{login}` via `wp_remote_get`. |
|
||||||
|
|
||||||
|
## 9. Template layer
|
||||||
|
|
||||||
|
The template engine uses the standard WordPress template-hierarchy lookup. The plugin registers `single-projects.php`, `archive-projects.php`, and `taxonomy-project-type.php` in its own `templates/` directory. Theme authors may override by copying any of these into their theme root.
|
||||||
|
|
||||||
|
The single-projects template:
|
||||||
|
1. Calls `projects_portfolio_get_repo_data($post_id)` once and reuses the result for the whole page.
|
||||||
|
2. Renders the buttons (Download, View Repo), the metadata table, the project-owner block, and the social-share action via `do_action('projects_after_download_button', $post_id)`.
|
||||||
|
3. Uses `background-image` (not `<img>`) for the owner avatar, with explicit pixel sizing (`width: 40px; height: 40px; background-size: 40px 40px`) because on some hosting environments the `cover` keyword for `background-size` is silently dropped and falls back to `auto auto` — which produces a tiny top-left-anchored rendering instead of a centered cover crop. Pixel values avoid that quirk.
|
||||||
|
|
||||||
|
## 10. Release workflow
|
||||||
|
|
||||||
|
`.github/workflows/release.yml` triggers on `v*` tag push. On `ubuntu-latest` it:
|
||||||
|
|
||||||
|
1. Checks out the repo at the tag's commit.
|
||||||
|
2. Builds the plugin zip via an embedded Python script (`zipfile` stdlib — `zip` CLI is not guaranteed on Gitea Actions runners).
|
||||||
|
3. Uploads as workflow artifact.
|
||||||
|
4. Creates or updates a release via the host's REST API:
|
||||||
|
- GitHub.com path: POST `https://api.github.com/repos/{owner}/{repo}/releases` using `secrets.GITHUB_TOKEN`, then upload asset via the per-release `upload_url` template.
|
||||||
|
- Gitea path: POST `{api}/v1/repos/{owner}/{repo}/releases` using `secrets.GITEA_TOKEN`, then POST the asset to `{api}/v1/repos/{owner}/{repo}/releases/{id}/assets`.
|
||||||
|
|
||||||
|
Both paths first **look up an existing release by tag** (idempotent on re-run), reusing its id if present. The asset upload uses `|| echo "::warning::Asset upload failed (asset may already exist); continuing."` so duplicate-asset 409s don't fail the run.
|
||||||
|
|
||||||
|
## 11. Plugin update checker (self-update)
|
||||||
|
|
||||||
|
The plugin ships a bundled copy of [YahnisElsts/plugin-update-checker](https://github.com/YahnisElsts/plugin-update-checker) v5.7 (latest upstream as of May 2026) so the plugin can advertise in-plugin update notifications when newer tags are pushed to the repo. Because the repo URL is a Gitea instance (not github.com), PUC falls back to its plain-JSON metadata mode and the plugin skips calling `setBranch()` — that method only exists on the VCS-specific adapter (`YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker` via the `VcsCheckerMethods` trait), and calling it on the plain metadata adapter throws `Error: Call to undefined method ...`.
|
||||||
|
|
||||||
|
## 12. Testing
|
||||||
|
|
||||||
|
PHPUnit under `tests/`. The bootstrap loads Brain\Monkey and stubs the small set of WP functions the providers call (`wp_remote_get`, `wp_remote_retrieve_response_code`, `wp_remote_retrieve_body`, `wp_remote_retrieve_header`, `is_wp_error`, `set_transient`, `get_transient`, `update_post_meta`, `get_post_meta`, `delete_post_meta`, `wp_unslash`, `esc_url_raw`, `error_log`).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- `test-github-provider.php`: URL construction, headers, `.zip` asset selection, `zipball_url` fallback, owner profile fetch.
|
||||||
|
- `test-gitea-provider.php`: URL construction, header behavior, field mapping (`stars_count → stargazers_count`, license normalization for string/null/object), `.zip` asset selection, archive fallback, **release-object shape (single object, not array)**, empty array handling, self-hosted base URL.
|
||||||
|
- `test-provider-factory.php`: provider selection from meta, legacy URL fallback, per-project Gitea base override.
|
||||||
|
- `test-metabox.php`: save persistence, lazy migration, provider-switch clearing of legacy URL, empty-repo clearing of legacy URL.
|
||||||
|
- `test-settings.php`: new option keys exposed via `projects_portfolio_settings()`.
|
||||||
|
|
||||||
|
## 13. i18n
|
||||||
|
|
||||||
|
Text domain `projects-wp`. Translation template at `languages/projects-wp.pot`; sibling `.po`/`.mo`/`.l10n.php` files for `es_MX` and `fr_FR`. All user-facing strings run through `esc_html_e()` / `esc_html__()` / `esc_attr_e()` / `esc_attr__()`.
|
||||||
|
|
||||||
|
## 14. Backward compatibility
|
||||||
|
|
||||||
|
- Legacy post meta key `_projects_portfolio_github_url` is read with a lazy fallback in the factory and the single-projects template.
|
||||||
|
- Legacy GitHub-only helper functions (`projects_portfolio_get_github_data`, `projects_portfolio_get_github_release_url`, `projects_portfolio_get_version_from_github`, `projects_portfolio_github_owner`) are retained verbatim with `_deprecated_function()` notices. New code uses the provider-aware wrappers.
|
||||||
|
- REST response shape retains the `github_url` and `github_data` field names (legacy consumers); adds a sibling `provider` field. New consumers should branch on `provider`.
|
||||||
|
|
||||||
|
## 15. Open follow-ups
|
||||||
|
|
||||||
|
- **In-tree version constant stays at the version that was first set for the Gitea-support release** (1.1.1) and has not been bumped with subsequent tags (1.1.2 → 1.1.14). The release workflow builds the zip from the tag name, but the plugin header reports the in-tree constant. Future releases should bump both at the same time. Tracked as a release-process gap, not a bug.
|
||||||
|
- **CSS cover keyword quirk** is currently worked around by using pixel values; future improvement is to investigate the host where `cover` falls back to `auto auto` and report upstream.
|
||||||
|
- **The bundled Lucide icons are a curated subset** (7 social networks). If a new network is added to the share buttons, drop a new `<name>.svg` into `assets/icons/` and add a `$svg_icon()` call in `projects_portfolio.php`.
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# Projects Portfolio
|
# Projects Portfolio
|
||||||
|
|
||||||
Create a beautifully structured, developer-first showcase directory for your WordPress® plugins, themes, and patterns - powered by [GitHub](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio).
|
Create a beautifully structured, developer-first showcase directory for your WordPress plugins, themes, and patterns - powered by git.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
**Projects Portfolio** adds a custom post type (`projects`) and taxonomy (`project-type`) to your WordPress® admin, allowing you to:
|
**Projects Portfolio** adds a custom post type (`projects`) and taxonomy (`project-type`) to your WordPress admin, allowing you to:
|
||||||
|
|
||||||
- Add plugins, themes, or patterns as projects.
|
- Add plugins, themes, or patterns as projects.
|
||||||
- Connect each project to a GitHub repository.
|
- Connect each project to a git repository.
|
||||||
- Automatically fetch and display data like:
|
- Automatically fetch and display data like:
|
||||||
- Latest version
|
- Latest version
|
||||||
- Stars, forks, issues
|
- Stars, forks, issues
|
||||||
@@ -27,14 +27,14 @@ Create a beautifully structured, developer-first showcase directory for your Wor
|
|||||||
### From GitHub
|
### From GitHub
|
||||||
|
|
||||||
1. **Download the latest ZIP** from the [GitHub Releases page](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio/releases).
|
1. **Download the latest ZIP** from the [GitHub Releases page](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio/releases).
|
||||||
2. Go to your WordPress® Dashboard → Plugins → Add New → Upload Plugin.
|
2. Go to your WordPress Dashboard → Plugins → Add New → Upload Plugin.
|
||||||
3. Upload the ZIP and activate the plugin.
|
3. Upload the ZIP and activate the plugin.
|
||||||
|
|
||||||
### From [keithsolomon.net](https://keithsolomon.net)
|
### From [keithsolomon.net](https://keithsolomon.net)
|
||||||
|
|
||||||
1. Visit the official plugin page: [Projects Portfolio](https://keithsolomon.net/projects/projects-portfolio).
|
1. Visit the official plugin page: [Projects Portfolio](https://keithsolomon.net/projects/projects-portfolio).
|
||||||
2. Download the ZIP.
|
2. Download the ZIP.
|
||||||
3. Install it via the WordPress® dashboard like above.
|
3. Install it via the WordPress dashboard like above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Create a beautifully structured, developer-first showcase directory for your Wor
|
|||||||
|
|
||||||
### Add a New Project
|
### Add a New Project
|
||||||
|
|
||||||
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, choose **Provider** (GitHub or Gitea) and paste the **Repository URL** (e.g., `https://github.com/username/repo` or `https://codeberg.org/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`).
|
||||||
@@ -79,7 +79,7 @@ Enable/disable the following GitHub data in your project views:
|
|||||||
- Language
|
- Language
|
||||||
- Downloads
|
- Downloads
|
||||||
- Stars / Forks / Issues
|
- Stars / Forks / Issues
|
||||||
- GitHub Owner
|
- Repo Owner
|
||||||
|
|
||||||
### Archive Settings
|
### Archive Settings
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ Toggle components for the project archive view:
|
|||||||
|
|
||||||
To connect a project to GitHub:
|
To connect a project to GitHub:
|
||||||
|
|
||||||
1. Paste the full repo URL into the **GitHub URL** field when editing a Project.
|
1. Paste the full repo URL into the **Repository URL** field when editing a Project.
|
||||||
2. Make sure your GitHub repo has a release with a `.zip` asset or a valid `zipball_url`.
|
2. Make sure your repo has a release with a `.zip` asset or a valid `zipball_url`.
|
||||||
|
|
||||||
### Optional: Add a GitHub API Token
|
### Optional: Add a GitHub API Token
|
||||||
|
|
||||||
@@ -121,18 +121,26 @@ To avoid rate limits or improve reliability:
|
|||||||
1. In your Gitea instance, go to **Settings → Applications** and generate a token.
|
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**.
|
2. Paste it into the plugin settings screen under **Gitea API Token**.
|
||||||
|
|
||||||
|
## Building a Release
|
||||||
|
|
||||||
|
Releases are automated via GitHub Actions. To cut a new release:
|
||||||
|
|
||||||
|
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
|
||||||
|
2. Commit and push to `main`.
|
||||||
|
3. Tag the release commit: `git tag -a v1.1.1 -m "v1.1.1 — short summary"`.
|
||||||
|
4. Push the tag: `git push origin v1.1.1`.
|
||||||
|
|
||||||
|
The `.github/workflows/release.yml` workflow runs and:
|
||||||
|
|
||||||
|
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
|
||||||
|
- Uploads the zip as a workflow artifact.
|
||||||
|
- Creates (or updates) the matching GitHub Release with the zip attached.
|
||||||
|
|
||||||
|
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
|
||||||
|
|
||||||
## How to Zip the Plugin for GitHub Releases
|
## How to Zip the Plugin for GitHub Releases
|
||||||
|
|
||||||
When attaching the plugin to a GitHub release:
|
This is now automated. See [Building a Release](#building-a-release) above — pushing a `v*` tag triggers `.github/workflows/release.yml` which builds and publishes the zip.
|
||||||
|
|
||||||
1. Zip **only the contents of the plugin folder**, not the parent folder.
|
|
||||||
2. Ensure `projects-portfolio.php` and `/includes`, `/admin`, `/templates`, `/assets` are at the root level of the ZIP.
|
|
||||||
3. Name the ZIP clearly (e.g. `projects-portfolio-1.0.0.zip`).
|
|
||||||
4. Go to your GitHub repo → **Releases** → **New Release**.
|
|
||||||
5. Tag the release with the version number (e.g., `1.0.0`).
|
|
||||||
6. Upload your correctly structured ZIP as a release asset.
|
|
||||||
|
|
||||||
Your users will be redirected to this file when they use the `/download/` endpoint.
|
|
||||||
|
|
||||||
## REST API Endpoints
|
## REST API Endpoints
|
||||||
|
|
||||||
@@ -186,14 +194,14 @@ Each project includes social sharing links for:
|
|||||||
|
|
||||||
## Support & Contributions
|
## Support & Contributions
|
||||||
|
|
||||||
Have a feature request or bug report? Open an issue on [GitHub](https://github.com/robertdevore/projects-portfolio/issues) or contribute via pull request.
|
Have a feature request or bug report? Open an issue on [the repo](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio/issues) or contribute via pull request.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Projects for WordPress® is licensed under the [GNU GPL v2+](http://www.gnu.org/licenses/gpl-2.0.txt).
|
Projects for WordPress is licensed under the [GNU GPL v2+](http://www.gnu.org/licenses/gpl-2.0.txt).
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
Built and maintained by [Robert DeVore](https://www.robertdevore.com).
|
Built and maintained by [Keith Solomon](https://keithsolomon.net).
|
||||||
|
|
||||||
Follow on [Twitter](https://twitter.com/deviorobert) or sponsor development on [GitHub Sponsors](https://github.com/sponsors/robertdevore).
|
Follow on [Twitter](https://twitter.com/KeithInWPG).
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' );
|
|||||||
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' );
|
||||||
|
|
||||||
|
$is_website = (bool) get_post_meta( $post->ID, '_projects_portfolio_is_website', true );
|
||||||
$provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
|
$provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
|
||||||
if ( '' === $provider ) {
|
if ( '' === $provider ) {
|
||||||
$provider = 'github';
|
$provider = 'github';
|
||||||
@@ -42,7 +43,19 @@ function projects_portfolio_render_meta_box( $post ) {
|
|||||||
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
|
$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 );
|
$gitea_base_url = get_post_meta( $post->ID, '_projects_portfolio_gitea_base_url', true );
|
||||||
|
|
||||||
|
$site_url = get_post_meta( $post->ID, '_projects_portfolio_site_url', true );
|
||||||
|
$site_platform = get_post_meta( $post->ID, '_projects_portfolio_site_platform', true );
|
||||||
|
$site_screenshot = (int) get_post_meta( $post->ID, '_projects_portfolio_site_screenshot_id', true );
|
||||||
?>
|
?>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="projects_portfolio_is_website" name="projects_portfolio_is_website" value="1" <?php checked( $is_website, true ); ?> />
|
||||||
|
<?php esc_html_e( 'This project is a website', 'projects-wp' ); ?>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="projects-portfolio-repo-fields" <?php echo $is_website ? 'style="display:none;"' : ''; ?>>
|
||||||
<p>
|
<p>
|
||||||
<label for="projects_portfolio_provider"><?php esc_html_e( 'Provider', 'projects-wp' ); ?></label>
|
<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%;">
|
<select id="projects_portfolio_provider" name="projects_portfolio_provider" style="width: 100%;">
|
||||||
@@ -59,22 +72,110 @@ function projects_portfolio_render_meta_box( $post ) {
|
|||||||
<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" />
|
<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>
|
<span class="description"><?php esc_html_e( 'Leave blank to use the default from Settings.', 'projects-wp' ); ?></span>
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="projects-portfolio-website-fields"<?php echo $is_website ? '' : ' style="display:none;"'; ?>>
|
||||||
|
<p>
|
||||||
|
<label for="projects_portfolio_site_url"><?php esc_html_e( 'Site URL', 'projects-wp' ); ?></label>
|
||||||
|
<input type="url" id="projects_portfolio_site_url" name="projects_portfolio_site_url" value="<?php echo esc_attr( $site_url ); ?>" style="width: 100%;" placeholder="https://example.com" />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="projects_portfolio_site_platform"><?php esc_html_e( 'Platform', 'projects-wp' ); ?></label>
|
||||||
|
<textarea id="projects_portfolio_site_platform" name="projects_portfolio_site_platform" rows="3" style="width: 100%;" placeholder="<?php esc_attr_e( 'e.g. WordPress, custom Next.js, etc.', 'projects-wp' ); ?>"><?php echo esc_textarea( $site_platform ); ?></textarea>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="projects_portfolio_site_screenshot_id"><?php esc_html_e( 'Screenshot (optional)', 'projects-wp' ); ?></label>
|
||||||
|
<span class="projects-portfolio-screenshot-preview" style="<?php echo $site_screenshot ? '' : 'display:none;'; ?> margin-right: 8px;">
|
||||||
|
<?php if ( $site_screenshot ) : ?>
|
||||||
|
<?php echo wp_get_attachment_image( $site_screenshot, 'thumbnail' ); // phpcs:ignore ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<input type="hidden" id="projects_portfolio_site_screenshot_id" name="projects_portfolio_site_screenshot_id" value="<?php echo esc_attr( $site_screenshot ); ?>" />
|
||||||
|
<button type="button" class="button projects-portfolio-screenshot-select"><?php esc_html_e( 'Select / Upload', 'projects-wp' ); ?></button>
|
||||||
|
<button type="button" class="button projects-portfolio-screenshot-clear" <?php echo $site_screenshot ? '' : 'style="display:none;"'; ?>><?php esc_html_e( 'Remove', 'projects-wp' ); ?></button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var sel = document.getElementById('projects_portfolio_provider');
|
var sel = document.getElementById('projects_portfolio_provider');
|
||||||
if (!sel) return;
|
if (sel) {
|
||||||
var gitea = document.querySelector('.projects-portfolio-gitea-only');
|
var gitea = document.querySelector('.projects-portfolio-gitea-only');
|
||||||
function toggle(){
|
function toggleGitea(){
|
||||||
if (!gitea) return;
|
if (!gitea) return;
|
||||||
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
|
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
|
||||||
}
|
}
|
||||||
sel.addEventListener('change', toggle);
|
sel.addEventListener('change', toggleGitea);
|
||||||
toggle();
|
toggleGitea();
|
||||||
|
}
|
||||||
|
|
||||||
|
var toggle = document.getElementById('projects_portfolio_is_website');
|
||||||
|
if (toggle) {
|
||||||
|
var repoBox = document.querySelector('.projects-portfolio-repo-fields');
|
||||||
|
var siteBox = document.querySelector('.projects-portfolio-website-fields');
|
||||||
|
function toggleMode(){
|
||||||
|
var on = !!toggle.checked;
|
||||||
|
if (repoBox) repoBox.style.display = on ? 'none' : '';
|
||||||
|
if (siteBox) siteBox.style.display = on ? '' : 'none';
|
||||||
|
}
|
||||||
|
toggle.addEventListener('change', toggleMode);
|
||||||
|
toggleMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame;
|
||||||
|
var selectBtn = document.querySelector('.projects-portfolio-screenshot-select');
|
||||||
|
var clearBtn = document.querySelector('.projects-portfolio-screenshot-clear');
|
||||||
|
var inputEl = document.getElementById('projects_portfolio_site_screenshot_id');
|
||||||
|
var previewEl = document.querySelector('.projects-portfolio-screenshot-preview');
|
||||||
|
function openFrame(){
|
||||||
|
if (typeof wp === 'undefined' || !wp.media) { return; }
|
||||||
|
frame = wp.media({
|
||||||
|
title: '<?php echo esc_js( __( 'Select Screenshot', 'projects-wp' ) ); ?>',
|
||||||
|
button: { text: '<?php echo esc_js( __( 'Use this image', 'projects-wp' ) ); ?>' },
|
||||||
|
library: { type: 'image' },
|
||||||
|
multiple: false
|
||||||
|
});
|
||||||
|
frame.on('select', function(){
|
||||||
|
var attachment = frame.state().get('selection').first().toJSON();
|
||||||
|
inputEl.value = attachment.id;
|
||||||
|
previewEl.innerHTML = '<img src="' + attachment.sizes && attachment.sizes.thumbnail ? attachment.sizes.thumbnail.url : attachment.url + '" alt="" />';
|
||||||
|
previewEl.style.display = '';
|
||||||
|
clearBtn.style.display = '';
|
||||||
|
});
|
||||||
|
frame.open();
|
||||||
|
}
|
||||||
|
if (selectBtn) { selectBtn.addEventListener('click', openFrame); }
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function(){
|
||||||
|
inputEl.value = '';
|
||||||
|
previewEl.innerHTML = '';
|
||||||
|
previewEl.style.display = 'none';
|
||||||
|
clearBtn.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue media picker assets for the project meta box.
|
||||||
|
*
|
||||||
|
* @since 1.4.0
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function projects_portfolio_enqueue_media_for_meta_box( $hook ) {
|
||||||
|
if ( ! in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||||
|
if ( ! $screen || 'projects' !== $screen->post_type ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_media();
|
||||||
|
}
|
||||||
|
add_action( 'admin_enqueue_scripts', 'projects_portfolio_enqueue_media_for_meta_box' );
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the Repository meta box.
|
* Save the Repository meta box.
|
||||||
*
|
*
|
||||||
@@ -103,6 +204,14 @@ function projects_portfolio_save_meta_box( $post_id ) {
|
|||||||
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
|
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "This project is a website" toggle — drives which set of fields is kept.
|
||||||
|
$is_website = isset( $_POST['projects_portfolio_is_website'] ) && '1' === $_POST['projects_portfolio_is_website'];
|
||||||
|
if ( $is_website ) {
|
||||||
|
update_post_meta( $post_id, '_projects_portfolio_is_website', '1' );
|
||||||
|
} else {
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_is_website' );
|
||||||
|
}
|
||||||
|
|
||||||
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
|
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
|
||||||
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
|
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
|
||||||
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
|
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
|
||||||
@@ -130,5 +239,43 @@ function projects_portfolio_save_meta_box( $post_id ) {
|
|||||||
if ( isset( $_POST['projects_portfolio_gitea_base_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'] ) ) );
|
update_post_meta( $post_id, '_projects_portfolio_gitea_base_url', esc_url_raw( wp_unslash( $_POST['projects_portfolio_gitea_base_url'] ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Website fields.
|
||||||
|
if ( isset( $_POST['projects_portfolio_site_url'] ) ) {
|
||||||
|
$site_url = esc_url_raw( wp_unslash( $_POST['projects_portfolio_site_url'] ) );
|
||||||
|
if ( '' === $site_url ) {
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_site_url' );
|
||||||
|
} else {
|
||||||
|
update_post_meta( $post_id, '_projects_portfolio_site_url', $site_url );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( isset( $_POST['projects_portfolio_site_platform'] ) ) {
|
||||||
|
$platform = sanitize_text_field( wp_unslash( $_POST['projects_portfolio_site_platform'] ) );
|
||||||
|
if ( '' === $platform ) {
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_site_platform' );
|
||||||
|
} else {
|
||||||
|
update_post_meta( $post_id, '_projects_portfolio_site_platform', $platform );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( isset( $_POST['projects_portfolio_site_screenshot_id'] ) ) {
|
||||||
|
$screenshot_id = absint( wp_unslash( $_POST['projects_portfolio_site_screenshot_id'] ) );
|
||||||
|
if ( $screenshot_id <= 0 ) {
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_site_screenshot_id' );
|
||||||
|
} else {
|
||||||
|
update_post_meta( $post_id, '_projects_portfolio_site_screenshot_id', $screenshot_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When modes switch, clear the opposite mode's fields so the project is cleanly one or the other.
|
||||||
|
if ( $is_website ) {
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_provider' );
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_repo_url' );
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_gitea_base_url' );
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
|
||||||
|
} else {
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_site_url' );
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_site_platform' );
|
||||||
|
delete_post_meta( $post_id, '_projects_portfolio_site_screenshot_id' );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
add_action( 'save_post', 'projects_portfolio_save_meta_box' );
|
add_action( 'save_post', 'projects_portfolio_save_meta_box' );
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-social-sharing a:hover {
|
.project-social-sharing a:hover {
|
||||||
@@ -35,6 +36,14 @@
|
|||||||
transition: stroke 0.3s;
|
transition: stroke 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tabler-style icons include a viewport <rect> with no fill; without
|
||||||
|
`stroke="none"` it inherits the parent's stroke color and renders as
|
||||||
|
a square outline inside the round button. Suppress that stroke on
|
||||||
|
the rect alone. */
|
||||||
|
.project-social-sharing svg rect {
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
.project-social-sharing a:hover svg {
|
.project-social-sharing a:hover svg {
|
||||||
stroke: #ffffff;
|
stroke: #ffffff;
|
||||||
}
|
}
|
||||||
@@ -82,16 +91,28 @@
|
|||||||
|
|
||||||
.project-owner img {
|
.project-owner img {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto 0;
|
margin: 0;
|
||||||
max-width: 50px;
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-owner span:last-of-type {
|
.project-owner-avatar {
|
||||||
display: flex;
|
flex: 0 0 auto;
|
||||||
justify-content: space-between;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
flex: 1;
|
justify-content: center;
|
||||||
align-items: end;
|
}
|
||||||
|
|
||||||
|
.project-owner-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-owner-follow {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-meta-table th {
|
.project-meta-table th {
|
||||||
@@ -119,7 +140,7 @@
|
|||||||
color: #FFF;
|
color: #FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-buttons a.button.project-github-button {
|
.project-buttons a.button.project-repo-button {
|
||||||
display: block;
|
display: block;
|
||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -128,9 +149,10 @@
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
border: 1px solid #c9c9c9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-buttons a.button.project-github-button:hover {
|
.project-buttons a.button.project-repo-button:hover {
|
||||||
background-color: #c9c9c9;
|
background-color: #c9c9c9;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-facebook"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-linkedin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
|
||||||
|
<rect width="4" height="12" x="2" y="9" />
|
||||||
|
<circle cx="4" cy="4" r="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-mail"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-pin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 17v5" />
|
||||||
|
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-share-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
|
||||||
|
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-twitter"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-message-circle"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.460.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-twitter"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 438 B |
@@ -104,6 +104,56 @@ function projects_portfolio_get_repo_browse_url( int $post_id ): string {
|
|||||||
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
|
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this project is configured as a website rather than a git repo.
|
||||||
|
*
|
||||||
|
* @since 1.4.0
|
||||||
|
* @param int $post_id
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
function projects_portfolio_is_website( int $post_id ): bool {
|
||||||
|
return (bool) get_post_meta( $post_id, '_projects_portfolio_is_website', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public URL for the project's website (when configured as a website).
|
||||||
|
*
|
||||||
|
* @since 1.4.0
|
||||||
|
* @param int $post_id
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function projects_portfolio_get_site_url( int $post_id ): string {
|
||||||
|
return (string) get_post_meta( $post_id, '_projects_portfolio_site_url', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform label for the project's website (when configured as a website).
|
||||||
|
*
|
||||||
|
* @since 1.4.0
|
||||||
|
* @param int $post_id
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function projects_portfolio_get_site_platform( int $post_id ): string {
|
||||||
|
return (string) get_post_meta( $post_id, '_projects_portfolio_site_platform', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screenshot attachment URL for the project's website.
|
||||||
|
*
|
||||||
|
* @since 1.4.0
|
||||||
|
* @param int $post_id
|
||||||
|
* @param string $size
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function projects_portfolio_get_site_screenshot_url( int $post_id, string $size = 'large' ): string {
|
||||||
|
$attachment_id = (int) get_post_meta( $post_id, '_projects_portfolio_site_screenshot_id', true );
|
||||||
|
if ( $attachment_id <= 0 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$src = wp_get_attachment_image_src( $attachment_id, $size );
|
||||||
|
return $src[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GitHub API call for owner data
|
* GitHub API call for owner data
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace YahnisElsts\PluginUpdateChecker\v5;
|
|||||||
|
|
||||||
if ( !class_exists(PucFactory::class, false) ):
|
if ( !class_exists(PucFactory::class, false) ):
|
||||||
|
|
||||||
class PucFactory extends \YahnisElsts\PluginUpdateChecker\v5p4\PucFactory {
|
class PucFactory extends \YahnisElsts\PluginUpdateChecker\v5p7\PucFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
endif;
|
endif;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
if ( !class_exists(Autoloader::class, false) ):
|
if ( !class_exists(Autoloader::class, false) ):
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory;
|
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker;
|
||||||
|
|
||||||
if ( !class_exists(Extension::class, false) ):
|
if ( !class_exists(Extension::class, false) ):
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker;
|
||||||
|
|
||||||
if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false) ):
|
if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false) ):
|
||||||
|
|
||||||
@@ -41,11 +41,11 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
|
|||||||
echo '<h3>Configuration</h3>';
|
echo '<h3>Configuration</h3>';
|
||||||
echo '<table class="puc-debug-data">';
|
echo '<table class="puc-debug-data">';
|
||||||
$this->displayConfigHeader();
|
$this->displayConfigHeader();
|
||||||
$this->row('Slug', htmlentities($this->updateChecker->slug));
|
$this->row('Slug', esc_html($this->updateChecker->slug));
|
||||||
$this->row('DB option', htmlentities($this->updateChecker->optionName));
|
$this->row('DB option', esc_html($this->updateChecker->optionName));
|
||||||
|
|
||||||
$requestInfoButton = $this->getMetadataButton();
|
$requestInfoButton = $this->getMetadataButton();
|
||||||
$this->row('Metadata URL', htmlentities($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox);
|
$this->row('Metadata URL', esc_html($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox);
|
||||||
|
|
||||||
$scheduler = $this->updateChecker->scheduler;
|
$scheduler = $this->updateChecker->scheduler;
|
||||||
if ( $scheduler->checkPeriod > 0 ) {
|
if ( $scheduler->checkPeriod > 0 ) {
|
||||||
@@ -86,14 +86,22 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
|
|||||||
echo '<h3>Status</h3>';
|
echo '<h3>Status</h3>';
|
||||||
echo '<table class="puc-debug-data">';
|
echo '<table class="puc-debug-data">';
|
||||||
$state = $this->updateChecker->getUpdateState();
|
$state = $this->updateChecker->getUpdateState();
|
||||||
$checkNowButton = '';
|
$checkButtonId = $this->updateChecker->getUniqueName('check-now-button');
|
||||||
if ( function_exists('get_submit_button') ) {
|
if ( function_exists('get_submit_button') ) {
|
||||||
$checkNowButton = get_submit_button(
|
$checkNowButton = get_submit_button(
|
||||||
'Check Now',
|
'Check Now',
|
||||||
'secondary',
|
'secondary',
|
||||||
'puc-check-now-button',
|
'puc-check-now-button',
|
||||||
false,
|
false,
|
||||||
array('id' => $this->updateChecker->getUniqueName('check-now-button'))
|
array('id' => $checkButtonId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
//get_submit_button() is not available in the frontend. Make a button directly.
|
||||||
|
//It won't look the same without admin styles, but it should still work.
|
||||||
|
$checkNowButton = sprintf(
|
||||||
|
'<input type="button" id="%1$s" name="puc-check-now-button" value="%2$s" class="button button-secondary" />',
|
||||||
|
esc_attr($checkButtonId),
|
||||||
|
esc_attr('Check Now')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +115,10 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
|
|||||||
$this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck));
|
$this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck));
|
||||||
|
|
||||||
if ( $state->getCheckedVersion() !== '' ) {
|
if ( $state->getCheckedVersion() !== '' ) {
|
||||||
$this->row('Checked version', htmlentities($state->getCheckedVersion()));
|
$this->row('Checked version', esc_html($state->getCheckedVersion()));
|
||||||
$this->row('Cached update', $state->getUpdate());
|
$this->row('Cached update', $state->getUpdate());
|
||||||
}
|
}
|
||||||
$this->row('Update checker class', htmlentities(get_class($this->updateChecker)));
|
$this->row('Update checker class', esc_html(get_class($this->updateChecker)));
|
||||||
echo '</table>';
|
echo '</table>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +132,7 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
|
|||||||
if ( property_exists($update, $field) ) {
|
if ( property_exists($update, $field) ) {
|
||||||
$this->row(
|
$this->row(
|
||||||
ucwords(str_replace('_', ' ', $field)),
|
ucwords(str_replace('_', ' ', $field)),
|
||||||
isset($update->$field) ? htmlentities($update->$field) : null
|
isset($update->$field) ? esc_html($update->$field) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +170,7 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
|
|||||||
if ( is_object($value) || is_array($value) ) {
|
if ( is_object($value) || is_array($value) ) {
|
||||||
//This is specifically for debugging, so print_r() is fine.
|
//This is specifically for debugging, so print_r() is fine.
|
||||||
//phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
|
//phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
|
||||||
$value = '<pre>' . htmlentities(print_r($value, true)) . '</pre>';
|
$value = '<pre>' . esc_html(print_r($value, true)) . '</pre>';
|
||||||
} else if ($value === null) {
|
} else if ($value === null) {
|
||||||
$value = '<code>null</code>';
|
$value = '<code>null</code>';
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker;
|
||||||
|
|
||||||
if ( !class_exists(PluginExtension::class, false) ):
|
if ( !class_exists(PluginExtension::class, false) ):
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker;
|
||||||
|
|
||||||
if ( !class_exists(PluginPanel::class, false) ):
|
if ( !class_exists(PluginPanel::class, false) ):
|
||||||
|
|
||||||
@@ -12,19 +12,25 @@ if ( !class_exists(PluginPanel::class, false) ):
|
|||||||
protected $updateChecker;
|
protected $updateChecker;
|
||||||
|
|
||||||
protected function displayConfigHeader() {
|
protected function displayConfigHeader() {
|
||||||
$this->row('Plugin file', htmlentities($this->updateChecker->pluginFile));
|
$this->row('Plugin file', esc_html($this->updateChecker->pluginFile));
|
||||||
parent::displayConfigHeader();
|
parent::displayConfigHeader();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getMetadataButton() {
|
protected function getMetadataButton() {
|
||||||
$requestInfoButton = '';
|
$buttonId = $this->updateChecker->getUniqueName('request-info-button');
|
||||||
if ( function_exists('get_submit_button') ) {
|
if ( function_exists('get_submit_button') ) {
|
||||||
$requestInfoButton = get_submit_button(
|
$requestInfoButton = get_submit_button(
|
||||||
'Request Info',
|
'Request Info',
|
||||||
'secondary',
|
'secondary',
|
||||||
'puc-request-info-button',
|
'puc-request-info-button',
|
||||||
false,
|
false,
|
||||||
array('id' => $this->updateChecker->getUniqueName('request-info-button'))
|
array('id' => $buttonId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$requestInfoButton = sprintf(
|
||||||
|
'<input type="button" name="puc-request-info-button" id="%1$s" value="%2$s" class="button button-secondary" />',
|
||||||
|
esc_attr($buttonId),
|
||||||
|
esc_attr('Request Info')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return $requestInfoButton;
|
return $requestInfoButton;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Theme\UpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Theme\UpdateChecker;
|
||||||
|
|
||||||
if ( !class_exists(ThemePanel::class, false) ):
|
if ( !class_exists(ThemePanel::class, false) ):
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ if ( !class_exists(ThemePanel::class, false) ):
|
|||||||
protected $updateChecker;
|
protected $updateChecker;
|
||||||
|
|
||||||
protected function displayConfigHeader() {
|
protected function displayConfigHeader() {
|
||||||
$this->row('Theme directory', htmlentities($this->updateChecker->directoryName));
|
$this->row('Theme directory', esc_html($this->updateChecker->directoryName));
|
||||||
parent::displayConfigHeader();
|
parent::displayConfigHeader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
if ( !class_exists(InstalledPackage::class, false) ):
|
if ( !class_exists(InstalledPackage::class, false) ):
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
use LogicException;
|
use LogicException;
|
||||||
use stdClass;
|
use stdClass;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
if ( !class_exists(OAuthSignature::class, false) ):
|
if ( !class_exists(OAuthSignature::class, false) ):
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
|
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory;
|
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory;
|
||||||
|
|
||||||
if ( !class_exists(Package::class, false) ):
|
if ( !class_exists(Package::class, false) ):
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Metadata;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Metadata;
|
||||||
|
|
||||||
if ( !class_exists(PluginInfo::class, false) ):
|
if ( !class_exists(PluginInfo::class, false) ):
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ if ( !class_exists(PluginInfo::class, false) ):
|
|||||||
public $downloaded;
|
public $downloaded;
|
||||||
public $active_installs;
|
public $active_installs;
|
||||||
public $last_updated;
|
public $last_updated;
|
||||||
|
public $autoupdate = false;
|
||||||
|
|
||||||
public $id = 0; //The native WP.org API returns numeric plugin IDs, but they're not used for anything.
|
public $id = 0; //The native WP.org API returns numeric plugin IDs, but they're not used for anything.
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
|
||||||
|
|
||||||
if ( !class_exists(Ui::class, false) ):
|
if ( !class_exists(Ui::class, false) ):
|
||||||
/**
|
/**
|
||||||
@@ -187,7 +187,7 @@ if ( !class_exists(Ui::class, false) ):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wp_redirect(add_query_arg(
|
wp_safe_redirect(add_query_arg(
|
||||||
array(
|
array(
|
||||||
'puc_update_check_result' => $status,
|
'puc_update_check_result' => $status,
|
||||||
'puc_slug' => $this->updateChecker->slug,
|
'puc_slug' => $this->updateChecker->slug,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Update as BaseUpdate;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Update as BaseUpdate;
|
||||||
|
|
||||||
if ( !class_exists(Update::class, false) ):
|
if ( !class_exists(Update::class, false) ):
|
||||||
|
|
||||||
@@ -20,9 +20,10 @@ if ( !class_exists(Update::class, false) ):
|
|||||||
public $requires_php = false;
|
public $requires_php = false;
|
||||||
public $icons = array();
|
public $icons = array();
|
||||||
public $filename; //Plugin filename relative to the plugins directory.
|
public $filename; //Plugin filename relative to the plugins directory.
|
||||||
|
public $autoupdate = false;
|
||||||
|
|
||||||
protected static $extraFields = array(
|
protected static $extraFields = array(
|
||||||
'id', 'homepage', 'tested', 'requires_php', 'upgrade_notice', 'icons', 'filename',
|
'id', 'homepage', 'tested', 'requires_php', 'upgrade_notice', 'icons', 'filename', 'autoupdate',
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +87,7 @@ if ( !class_exists(Update::class, false) ):
|
|||||||
$update->tested = $this->tested;
|
$update->tested = $this->tested;
|
||||||
$update->requires_php = $this->requires_php;
|
$update->requires_php = $this->requires_php;
|
||||||
$update->plugin = $this->filename;
|
$update->plugin = $this->filename;
|
||||||
|
$update->autoupdate = $this->autoupdate;
|
||||||
|
|
||||||
if ( !empty($this->upgrade_notice) ) {
|
if ( !empty($this->upgrade_notice) ) {
|
||||||
$update->upgrade_notice = $this->upgrade_notice;
|
$update->upgrade_notice = $this->upgrade_notice;
|
||||||
@@ -102,7 +104,7 @@ if ( !class_exists(Update::class, false) ):
|
|||||||
$update->icons = $icons;
|
$update->icons = $icons;
|
||||||
|
|
||||||
//It appears that the 'default' icon isn't used anywhere in WordPress 4.9,
|
//It appears that the 'default' icon isn't used anywhere in WordPress 4.9,
|
||||||
//but lets set it just in case a future release needs it.
|
//but let's set it just in case a future release needs it.
|
||||||
if ( !isset($update->icons['default']) ) {
|
if ( !isset($update->icons['default']) ) {
|
||||||
$update->icons['default'] = current($update->icons);
|
$update->icons['default'] = current($update->icons);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
|
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker as BaseUpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker as BaseUpdateChecker;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Scheduler;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Scheduler;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
|
use YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
|
||||||
|
|
||||||
if ( !class_exists(UpdateChecker::class, false) ):
|
if ( !class_exists(UpdateChecker::class, false) ):
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Theme;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Theme;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
if ( !class_exists(PucFactory::class, false) ):
|
if ( !class_exists(PucFactory::class, false) ):
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ if ( !class_exists(PucFactory::class, false) ):
|
|||||||
throw new \RuntimeException(sprintf(
|
throw new \RuntimeException(sprintf(
|
||||||
'The update checker cannot determine if "%s" is a plugin or a theme. ' .
|
'The update checker cannot determine if "%s" is a plugin or a theme. ' .
|
||||||
'This is a bug. Please contact the PUC developer.',
|
'This is a bug. Please contact the PUC developer.',
|
||||||
htmlentities($fullPath)
|
esc_html($fullPath)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ if ( !class_exists(PucFactory::class, false) ):
|
|||||||
*
|
*
|
||||||
* Normalize a filesystem path. Introduced in WP 3.9.
|
* Normalize a filesystem path. Introduced in WP 3.9.
|
||||||
* Copying here allows use of the class on earlier versions.
|
* Copying here allows use of the class on earlier versions.
|
||||||
* This version adapted from WP 4.8.2 (unchanged since 4.5.4)
|
* This version adapted from WP 4.8.2 (unchanged since 4.5.7)
|
||||||
*
|
*
|
||||||
* @param string $path Path to normalize.
|
* @param string $path Path to normalize.
|
||||||
* @return string Normalized path.
|
* @return string Normalized path.
|
||||||
@@ -239,7 +239,7 @@ if ( !class_exists(PucFactory::class, false) ):
|
|||||||
|
|
||||||
//URI was not found so throw an error.
|
//URI was not found so throw an error.
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
sprintf('Unable to locate URI in header of "%s"', htmlentities($fullPath))
|
sprintf('Unable to locate URI in header of "%s"', esc_html($fullPath))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
if ( !class_exists(Scheduler::class, false) ):
|
if ( !class_exists(Scheduler::class, false) ):
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
if ( !class_exists(StateStore::class, false) ):
|
if ( !class_exists(StateStore::class, false) ):
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ if ( !class_exists(StateStore::class, false) ):
|
|||||||
* @param Update|null $update
|
* @param Update|null $update
|
||||||
* @return $this
|
* @return $this
|
||||||
*/
|
*/
|
||||||
public function setUpdate(Update $update = null) {
|
public function setUpdate($update = null) {
|
||||||
$this->lazyLoad();
|
$this->lazyLoad();
|
||||||
$this->update = $update;
|
$this->update = $update;
|
||||||
return $this;
|
return $this;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
|
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
|
||||||
|
|
||||||
if ( !class_exists(Package::class, false) ):
|
if ( !class_exists(Package::class, false) ):
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Update as BaseUpdate;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Update as BaseUpdate;
|
||||||
|
|
||||||
if ( !class_exists(Update::class, false) ):
|
if ( !class_exists(Update::class, false) ):
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker as BaseUpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker as BaseUpdateChecker;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
|
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Scheduler;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Scheduler;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
|
use YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
|
||||||
|
|
||||||
if ( !class_exists(UpdateChecker::class, false) ):
|
if ( !class_exists(UpdateChecker::class, false) ):
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
use stdClass;
|
use stdClass;
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
use stdClass;
|
use stdClass;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -171,6 +171,10 @@ if ( !class_exists(UpdateChecker::class, false) ):
|
|||||||
//Allow HTTP requests to the metadata URL even if it's on a local host.
|
//Allow HTTP requests to the metadata URL even if it's on a local host.
|
||||||
add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2);
|
add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2);
|
||||||
|
|
||||||
|
//Potentially exclude information about this entity from core update check requests to api.wordpress.org.
|
||||||
|
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- Doesn't modify timeouts.
|
||||||
|
add_filter('http_request_args', array($this, 'excludeEntityFromWordPressAPI'), 10, 2);
|
||||||
|
|
||||||
//DebugBar integration.
|
//DebugBar integration.
|
||||||
if ( did_action('plugins_loaded') ) {
|
if ( did_action('plugins_loaded') ) {
|
||||||
$this->maybeInitDebugBar();
|
$this->maybeInitDebugBar();
|
||||||
@@ -192,6 +196,7 @@ if ( !class_exists(UpdateChecker::class, false) ):
|
|||||||
|
|
||||||
remove_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10);
|
remove_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10);
|
||||||
remove_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10);
|
remove_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10);
|
||||||
|
remove_filter('http_request_args', array($this, 'excludeEntityFromWordPressAPI'));
|
||||||
remove_action('plugins_loaded', array($this, 'maybeInitDebugBar'));
|
remove_action('plugins_loaded', array($this, 'maybeInitDebugBar'));
|
||||||
|
|
||||||
remove_action('init', array($this, 'loadTextDomain'));
|
remove_action('init', array($this, 'loadTextDomain'));
|
||||||
@@ -266,6 +271,76 @@ if ( !class_exists(UpdateChecker::class, false) ):
|
|||||||
*/
|
*/
|
||||||
abstract protected function createScheduler($checkPeriod);
|
abstract protected function createScheduler($checkPeriod);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove information about this plugin or theme from the requests that WordPress core sends
|
||||||
|
* to api.wordpress.org when checking for updates.
|
||||||
|
*
|
||||||
|
* @param array $args
|
||||||
|
* @param string $url
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function excludeEntityFromWordPressAPI($args, $url) {
|
||||||
|
//Is this an api.wordpress.org update check request?
|
||||||
|
$parsedUrl = wp_parse_url($url);
|
||||||
|
if ( !isset($parsedUrl['host']) || (strtolower($parsedUrl['host']) !== 'api.wordpress.org') ) {
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typePluralised = $this->componentType . 's';
|
||||||
|
$expectedPathPrefix = '/' . $typePluralised . '/update-check/1.'; //e.g. "/plugins/update-check/1.1/"
|
||||||
|
if ( !isset($parsedUrl['path']) || !Utils::startsWith($parsedUrl['path'], $expectedPathPrefix) ) {
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Plugins and themes can disable this feature by using the filter below.
|
||||||
|
if ( !apply_filters(
|
||||||
|
$this->getUniqueName('remove_from_default_update_checks'),
|
||||||
|
true, $this, $args, $url
|
||||||
|
) ) {
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty($args['body'][$typePluralised]) ) {
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportingItems = json_decode($args['body'][$typePluralised], true);
|
||||||
|
if ( $reportingItems === null ) {
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
|
//The list of installed items uses different key formats for plugins and themes.
|
||||||
|
//Luckily, we can reuse the getUpdateListKey() method here.
|
||||||
|
$updateListKey = $this->getUpdateListKey();
|
||||||
|
if ( isset($reportingItems[$typePluralised][$updateListKey]) ) {
|
||||||
|
unset($reportingItems[$typePluralised][$updateListKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !empty($reportingItems['active']) ) {
|
||||||
|
if ( is_array($reportingItems['active']) ) {
|
||||||
|
foreach ($reportingItems['active'] as $index => $relativePath) {
|
||||||
|
if ( $relativePath === $updateListKey ) {
|
||||||
|
unset($reportingItems['active'][$index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Re-index the array.
|
||||||
|
$reportingItems['active'] = array_values($reportingItems['active']);
|
||||||
|
} else if ( $reportingItems['active'] === $updateListKey ) {
|
||||||
|
//For themes, the "active" field is a string that contains the theme's directory name.
|
||||||
|
//Pretend that the default theme is active so that we don't reveal the actual theme.
|
||||||
|
if ( defined('WP_DEFAULT_THEME') ) {
|
||||||
|
$reportingItems['active'] = WP_DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Unfortunately, it doesn't seem to be documented if we can safely remove the "active"
|
||||||
|
//key. So when we don't know the default theme, we'll just leave it as is.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$args['body'][$typePluralised] = wp_json_encode($reportingItems);
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for updates. The results are stored in the DB option specified in $optionName.
|
* Check for updates. The results are stored in the DB option specified in $optionName.
|
||||||
*
|
*
|
||||||
@@ -384,7 +459,7 @@ if ( !class_exists(UpdateChecker::class, false) ):
|
|||||||
*
|
*
|
||||||
* @param Metadata|null $update
|
* @param Metadata|null $update
|
||||||
*/
|
*/
|
||||||
protected function fixSupportedWordpressVersion(Metadata $update = null) {
|
protected function fixSupportedWordpressVersion($update = null) {
|
||||||
if ( !isset($update->tested) || !preg_match('/^\d++\.\d++$/', $update->tested) ) {
|
if ( !isset($update->tested) || !preg_match('/^\d++\.\d++$/', $update->tested) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -557,7 +632,15 @@ if ( !class_exists(UpdateChecker::class, false) ):
|
|||||||
if ( !empty($update) ) {
|
if ( !empty($update) ) {
|
||||||
//Let plugins filter the update info before it's passed on to WordPress.
|
//Let plugins filter the update info before it's passed on to WordPress.
|
||||||
$update = apply_filters($this->getUniqueName('pre_inject_update'), $update);
|
$update = apply_filters($this->getUniqueName('pre_inject_update'), $update);
|
||||||
$updates = $this->addUpdateToList($updates, $update->toWpFormat());
|
//Convert the update into the format used by WordPress core.
|
||||||
|
$wpUpdate = $update->toWpFormat();
|
||||||
|
//Disable the "autoupdate" flag unless explicitly allowed. This is a safety precaution;
|
||||||
|
//untrusted or compromised update sources could otherwise set this flag to true even
|
||||||
|
//if the plugin/theme developer didn't intend to allow automatic update installation.
|
||||||
|
if ( isset($wpUpdate->autoupdate) && !$this->isAutoupdateFieldAllowed($update) ) {
|
||||||
|
$wpUpdate->autoupdate = false;
|
||||||
|
}
|
||||||
|
$updates = $this->addUpdateToList($updates, $wpUpdate);
|
||||||
} else {
|
} else {
|
||||||
//Clean up any stale update info.
|
//Clean up any stale update info.
|
||||||
$updates = $this->removeUpdateFromList($updates);
|
$updates = $this->removeUpdateFromList($updates);
|
||||||
@@ -655,6 +738,38 @@ if ( !class_exists(UpdateChecker::class, false) ):
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $autoupdateFieldAllowed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow the "autoupdate" field in incoming plugin updates to be set to `true`.
|
||||||
|
*
|
||||||
|
* By default, the update checker will parse the field (so it will be available in the update
|
||||||
|
* object), but will set it to `false` before passing the update to WordPress.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function allowAutoupdateField() {
|
||||||
|
$this->autoupdateFieldAllowed = true;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the "autoupdate" field for injected updates allowed to be set to true?
|
||||||
|
*
|
||||||
|
* @param object $incomingUpdate
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function isAutoupdateFieldAllowed($incomingUpdate) {
|
||||||
|
return apply_filters(
|
||||||
|
$this->getUniqueName('autoupdate_field_allowed'),
|
||||||
|
$this->autoupdateFieldAllowed,
|
||||||
|
$incomingUpdate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------
|
/* -------------------------------------------------------------------
|
||||||
* JSON-based update API
|
* JSON-based update API
|
||||||
* -------------------------------------------------------------------
|
* -------------------------------------------------------------------
|
||||||
@@ -924,25 +1039,62 @@ if ( !class_exists(UpdateChecker::class, false) ):
|
|||||||
return $source;
|
return $source;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Rename the source to match the existing directory.
|
//Fix the remote source structure if necessary.
|
||||||
$correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/';
|
|
||||||
if ( $source !== $correctedSource ) {
|
|
||||||
//The update archive should contain a single directory that contains the rest of plugin/theme files.
|
//The update archive should contain a single directory that contains the rest of plugin/theme files.
|
||||||
//Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource).
|
//Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource).
|
||||||
//We can't rename $remoteSource because that would break WordPress code that cleans up temporary files
|
//We can't rename $remoteSource because that would break WordPress code that cleans up temporary files
|
||||||
//after update.
|
//after update.
|
||||||
if ( $this->isBadDirectoryStructure($remoteSource) ) {
|
if ( $this->isBadDirectoryStructure($remoteSource) ) {
|
||||||
|
//Create a new directory using the plugin slug.
|
||||||
|
$newDirectory = trailingslashit($remoteSource) . $this->slug . '/';
|
||||||
|
|
||||||
|
if ( !$wp_filesystem->is_dir($newDirectory) ) {
|
||||||
|
$wp_filesystem->mkdir($newDirectory);
|
||||||
|
|
||||||
|
//Move all files to the newly created directory.
|
||||||
|
$sourceFiles = $wp_filesystem->dirlist($remoteSource);
|
||||||
|
if ( is_array($sourceFiles) ) {
|
||||||
|
$sourceFiles = array_keys($sourceFiles);
|
||||||
|
$allMoved = true;
|
||||||
|
foreach ($sourceFiles as $filename) {
|
||||||
|
//Skip our newly created folder.
|
||||||
|
if ( $filename === $this->slug ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousSource = trailingslashit($remoteSource) . $filename;
|
||||||
|
$newSource = trailingslashit($newDirectory) . $filename;
|
||||||
|
|
||||||
|
if ( !$wp_filesystem->move($previousSource, $newSource, true) ) {
|
||||||
|
$allMoved = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $allMoved ) {
|
||||||
|
//Rename source.
|
||||||
|
$source = $newDirectory;
|
||||||
|
} else {
|
||||||
|
//Delete our newly created folder including all files in it.
|
||||||
|
$wp_filesystem->rmdir($newDirectory, true);
|
||||||
|
|
||||||
|
//And return a relevant error.
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'puc-incorrect-directory-structure',
|
'puc-incorrect-directory-structure',
|
||||||
sprintf(
|
sprintf(
|
||||||
'The directory structure of the update is incorrect. All files should be inside ' .
|
'The directory structure of the update was incorrect. All files should be inside ' .
|
||||||
'a directory named <span class="code">%s</span>, not at the root of the ZIP archive.',
|
'a directory named <span class="code">%s</span>, not at the root of the ZIP archive. Plugin Update Checker tried to fix the directory structure, but failed.',
|
||||||
htmlentities($this->slug)
|
esc_html($this->slug)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @var \WP_Upgrader_Skin $upgrader ->skin */
|
//Rename the source to match the existing directory.
|
||||||
|
$correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/';
|
||||||
|
if ( $source !== $correctedSource ) {
|
||||||
$upgrader->skin->feedback(sprintf(
|
$upgrader->skin->feedback(sprintf(
|
||||||
'Renaming %s to %s…',
|
'Renaming %s to %s…',
|
||||||
'<span class="code">' . basename($source) . '</span>',
|
'<span class="code">' . basename($source) . '</span>',
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
if ( !class_exists(UpgraderStatus::class, false) ):
|
if ( !class_exists(UpgraderStatus::class, false) ):
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
if ( !class_exists(Utils::class, false) ):
|
if ( !class_exists(Utils::class, false) ):
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
use Parsedown;
|
use Parsedown;
|
||||||
use PucReadmeParser;
|
use PucReadmeParser;
|
||||||
@@ -51,6 +51,16 @@ if ( !class_exists(Api::class, false) ):
|
|||||||
*/
|
*/
|
||||||
protected $credentials = null;
|
protected $credentials = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string|null The value of the "Authorization" header for API requests.
|
||||||
|
*/
|
||||||
|
private $authorizationHeader = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string|null If set, add the "Authorization" header to update downloads that start with this prefix.
|
||||||
|
*/
|
||||||
|
private $downloadUrlPrefixRequiringAuth = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string The filter tag that's used to filter options passed to wp_remote_get.
|
* @var string The filter tag that's used to filter options passed to wp_remote_get.
|
||||||
* For example, "puc_request_info_options-slug" or "puc_request_update_options_theme-slug".
|
* For example, "puc_request_info_options-slug" or "puc_request_update_options_theme-slug".
|
||||||
@@ -322,6 +332,22 @@ if ( !class_exists(Api::class, false) ):
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function getApiRequestHttpOptions() {
|
||||||
|
$options = ['timeout' => wp_doing_cron() ? 10 : 3];
|
||||||
|
if ( $this->isAuthenticationEnabled() && !empty($this->authorizationHeader) ) {
|
||||||
|
$options['headers'] = ['Authorization' => $this->authorizationHeader];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !empty($this->httpFilterName) ) {
|
||||||
|
$options = apply_filters($this->httpFilterName, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set authentication credentials.
|
* Set authentication credentials.
|
||||||
*
|
*
|
||||||
@@ -332,7 +358,130 @@ if ( !class_exists(Api::class, false) ):
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function isAuthenticationEnabled() {
|
public function isAuthenticationEnabled() {
|
||||||
return !empty($this->credentials);
|
return !empty($this->credentials) || !empty($this->authorizationHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value of the "Authorization" header for API requests, if any.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function getAuthorizationHeader() {
|
||||||
|
return $this->authorizationHeader ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable basic access authentication with the specified username and password.
|
||||||
|
*
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string|null $downloadUrlPrefix Optionally, add the same Authorization header to update
|
||||||
|
* downloads where the download URL starts with this prefix.
|
||||||
|
*/
|
||||||
|
protected function enableBasicAuth($username, $password, $downloadUrlPrefix = null) {
|
||||||
|
$this->authorizationHeader = 'Basic ' . base64_encode($username . ':' . $password);
|
||||||
|
|
||||||
|
$this->downloadUrlPrefixRequiringAuth = $downloadUrlPrefix;
|
||||||
|
if ( !empty($this->downloadUrlPrefixRequiringAuth) ) {
|
||||||
|
$this->enableDownloadRequestFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Whether the hook that registers download request filter(s) has already been added.
|
||||||
|
*/
|
||||||
|
private $preDownloadHookAdded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the hooks that let you filter HTTP requests for update downloads.
|
||||||
|
*/
|
||||||
|
protected function enableDownloadRequestFilter() {
|
||||||
|
if ( $this->preDownloadHookAdded ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->preDownloadHookAdded = true;
|
||||||
|
|
||||||
|
//Optimization: Instead of filtering all HTTP requests, let's do it only when
|
||||||
|
//WordPress is about to download an update. So this is a two-step process;
|
||||||
|
//the actual request arg filter is added in the following hook.
|
||||||
|
add_filter('upgrader_pre_download', [$this, 'addDownloadRequestFilters']); //WP 3.7+
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
private $downloadFiltersAdded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
* @param bool $result Pass-through value for the "upgrader_pre_download" filter. Ignored by this callback.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function addDownloadRequestFilters($result) {
|
||||||
|
if ( !$this->downloadFiltersAdded ) {
|
||||||
|
$this->downloadFiltersAdded = true;
|
||||||
|
|
||||||
|
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
|
||||||
|
add_filter('http_request_args', [$this, 'filterUpdateDownloadRequestArgs'], 10, 2);
|
||||||
|
|
||||||
|
$authorizationHeader = $this->getAuthorizationHeader();
|
||||||
|
if ( $this->isAuthenticationEnabled() && !empty($authorizationHeader) ) {
|
||||||
|
add_action('requests-requests.before_redirect', [$this, 'removeAuthHeaderFromRedirects'], 10, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter request arguments/options for update downloads.
|
||||||
|
*
|
||||||
|
* Note that this callback can potentially be called for *any* update download. You still
|
||||||
|
* need to verify that the URL is one of yours.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @param array $requestArgs
|
||||||
|
* @param string $url
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function filterUpdateDownloadRequestArgs($requestArgs, $url = '') {
|
||||||
|
//Add an authorization header to our downloads if needed.
|
||||||
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
if (
|
||||||
|
$this->isAuthenticationEnabled()
|
||||||
|
&& !empty($authHeader)
|
||||||
|
&& !empty($this->downloadUrlPrefixRequiringAuth)
|
||||||
|
&& ((strpos($url, $this->downloadUrlPrefixRequiringAuth)) === 0)
|
||||||
|
) {
|
||||||
|
$requestArgs['headers']['Authorization'] = $authHeader;
|
||||||
|
}
|
||||||
|
return $requestArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At least in older WP versions, when following a redirect, the Requests library will
|
||||||
|
* automatically forward the Authorization header to other hosts. We don't want that
|
||||||
|
* because it breaks AWS downloads and can leak authorization information.
|
||||||
|
*
|
||||||
|
* @param string $location
|
||||||
|
* @param array $headers
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function removeAuthHeaderFromRedirects(&$location, &$headers) {
|
||||||
|
if (
|
||||||
|
//If there's no download URL prefix configured, we would not have added an auth header,
|
||||||
|
//and there's also no way to check if this URL needs auth or not.
|
||||||
|
empty($this->downloadUrlPrefixRequiringAuth)
|
||||||
|
//If this request goes to our download URL, we can leave the header.
|
||||||
|
|| ((strpos($location, $this->downloadUrlPrefixRequiringAuth)) === 0)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Remove the header.
|
||||||
|
$authorizationHeader = $this->getAuthorizationHeader();
|
||||||
|
if ( isset($headers['Authorization']) && ($headers['Authorization'] === $authorizationHeader) ) {
|
||||||
|
unset($headers['Authorization']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
if ( !interface_exists(BaseChecker::class, false) ):
|
if ( !interface_exists(BaseChecker::class, false) ):
|
||||||
|
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\OAuthSignature;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Utils;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Utils;
|
|
||||||
|
|
||||||
if ( !class_exists(BitBucketApi::class, false) ):
|
if ( !class_exists(BitBucketApi::class, false) ):
|
||||||
|
|
||||||
class BitBucketApi extends Api {
|
class BitBucketApi extends Api {
|
||||||
/**
|
|
||||||
* @var OAuthSignature
|
|
||||||
*/
|
|
||||||
private $oauth = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
@@ -148,11 +142,19 @@ if ( !class_exists(BitBucketApi::class, false) ):
|
|||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
protected function getDownloadUrl($ref) {
|
protected function getDownloadUrl($ref) {
|
||||||
|
return $this->getDownloadBaseUrl() . $ref . '.zip';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for ZIP downloads from our repo. Includes the trailing slash.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function getDownloadBaseUrl() {
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'https://bitbucket.org/%s/%s/get/%s.zip',
|
'https://bitbucket.org/%s/%s/get/',
|
||||||
$this->username,
|
$this->username,
|
||||||
$this->repository,
|
$this->repository
|
||||||
$ref
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +208,7 @@ if ( !class_exists(BitBucketApi::class, false) ):
|
|||||||
));
|
));
|
||||||
$baseUrl = $url;
|
$baseUrl = $url;
|
||||||
|
|
||||||
if ( $this->oauth ) {
|
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
|
||||||
$url = $this->oauth->sign($url,'GET');
|
|
||||||
}
|
|
||||||
|
|
||||||
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
|
|
||||||
if ( !empty($this->httpFilterName) ) {
|
|
||||||
$options = apply_filters($this->httpFilterName, $options);
|
|
||||||
}
|
|
||||||
$response = wp_remote_get($url, $options);
|
|
||||||
if ( is_wp_error($response) ) {
|
if ( is_wp_error($response) ) {
|
||||||
do_action('puc_api_error', $response, null, $url, $this->slug);
|
do_action('puc_api_error', $response, null, $url, $this->slug);
|
||||||
return $response;
|
return $response;
|
||||||
@@ -248,25 +242,18 @@ if ( !class_exists(BitBucketApi::class, false) ):
|
|||||||
public function setAuthentication($credentials) {
|
public function setAuthentication($credentials) {
|
||||||
parent::setAuthentication($credentials);
|
parent::setAuthentication($credentials);
|
||||||
|
|
||||||
if ( !empty($credentials) && !empty($credentials['consumer_key']) ) {
|
if (
|
||||||
$this->oauth = new OAuthSignature(
|
is_array($credentials)
|
||||||
$credentials['consumer_key'],
|
&& !empty($credentials['username'])
|
||||||
$credentials['consumer_secret']
|
&& !empty($credentials['api_token'])
|
||||||
|
) {
|
||||||
|
$this->enableBasicAuth(
|
||||||
|
$credentials['username'],
|
||||||
|
$credentials['api_token'],
|
||||||
|
$this->getDownloadBaseUrl()
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
$this->oauth = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function signDownloadUrl($url) {
|
|
||||||
//Add authentication data to download URLs. Since OAuth signatures incorporate
|
|
||||||
//timestamps, we have to do this immediately before inserting the update. Otherwise,
|
|
||||||
//authentication could fail due to a stale timestamp.
|
|
||||||
if ( $this->oauth ) {
|
|
||||||
$url = $this->oauth->sign($url);
|
|
||||||
}
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endif;
|
endif;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
use Parsedown;
|
use Parsedown;
|
||||||
|
|
||||||
@@ -29,11 +29,6 @@ if ( !class_exists(GitHubApi::class, false) ):
|
|||||||
*/
|
*/
|
||||||
protected $accessToken;
|
protected $accessToken;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
private $downloadFilterAdded = false;
|
|
||||||
|
|
||||||
public function __construct($repositoryUrl, $accessToken = null) {
|
public function __construct($repositoryUrl, $accessToken = null) {
|
||||||
$path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
|
$path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
|
||||||
if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
|
if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
|
||||||
@@ -248,15 +243,7 @@ if ( !class_exists(GitHubApi::class, false) ):
|
|||||||
$baseUrl = $url;
|
$baseUrl = $url;
|
||||||
$url = $this->buildApiUrl($url, $queryParams);
|
$url = $this->buildApiUrl($url, $queryParams);
|
||||||
|
|
||||||
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
|
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
|
||||||
if ( $this->isAuthenticationEnabled() ) {
|
|
||||||
$options['headers'] = array('Authorization' => $this->getAuthorizationHeader());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( !empty($this->httpFilterName) ) {
|
|
||||||
$options = apply_filters($this->httpFilterName, $options);
|
|
||||||
}
|
|
||||||
$response = wp_remote_get($url, $options);
|
|
||||||
if ( is_wp_error($response) ) {
|
if ( is_wp_error($response) ) {
|
||||||
do_action('puc_api_error', $response, null, $url, $this->slug);
|
do_action('puc_api_error', $response, null, $url, $this->slug);
|
||||||
return $response;
|
return $response;
|
||||||
@@ -348,11 +335,17 @@ if ( !class_exists(GitHubApi::class, false) ):
|
|||||||
|
|
||||||
public function setAuthentication($credentials) {
|
public function setAuthentication($credentials) {
|
||||||
parent::setAuthentication($credentials);
|
parent::setAuthentication($credentials);
|
||||||
|
//This property is no longer used internally, but is kept for backwards compatibility.
|
||||||
$this->accessToken = is_string($credentials) ? $credentials : null;
|
$this->accessToken = is_string($credentials) ? $credentials : null;
|
||||||
|
|
||||||
//Optimization: Instead of filtering all HTTP requests, let's do it only when
|
if ( is_string($credentials) && !empty($credentials) ) {
|
||||||
//WordPress is about to download an update.
|
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', []);
|
||||||
add_filter('upgrader_pre_download', array($this, 'addHttpRequestFilter'), 10, 1); //WP 3.7+
|
$this->enableBasicAuth($this->userName, $credentials, $repoApiBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Assets sometimes need an Accept header, so we add a request filter even if basic
|
||||||
|
//authentication isn't enabled.
|
||||||
|
$this->enableDownloadRequestFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getUpdateDetectionStrategies($configBranch) {
|
protected function getUpdateDetectionStrategies($configBranch) {
|
||||||
@@ -393,74 +386,15 @@ if ( !class_exists(GitHubApi::class, false) ):
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function filterUpdateDownloadRequestArgs($requestArgs, $url = '') {
|
||||||
* @param bool $result
|
//Release assets need an "Accept" header. See GitHub Docs:
|
||||||
* @return bool
|
//https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
public function addHttpRequestFilter($result) {
|
|
||||||
if ( !$this->downloadFilterAdded && $this->isAuthenticationEnabled() ) {
|
|
||||||
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
|
|
||||||
add_filter('http_request_args', array($this, 'setUpdateDownloadHeaders'), 10, 2);
|
|
||||||
add_action('requests-requests.before_redirect', array($this, 'removeAuthHeaderFromRedirects'), 10, 4);
|
|
||||||
$this->downloadFilterAdded = true;
|
|
||||||
}
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the HTTP headers that are necessary to download updates from private repositories.
|
|
||||||
*
|
|
||||||
* See GitHub docs:
|
|
||||||
*
|
|
||||||
* @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
|
|
||||||
* @link https://developer.github.com/v3/auth/#basic-authentication
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
* @param array $requestArgs
|
|
||||||
* @param string $url
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function setUpdateDownloadHeaders($requestArgs, $url = '') {
|
|
||||||
//Is WordPress trying to download one of our release assets?
|
//Is WordPress trying to download one of our release assets?
|
||||||
if ( $this->releaseAssetsEnabled && (strpos($url, $this->getAssetApiBaseUrl()) !== false) ) {
|
if ( $this->releaseAssetsEnabled && (strpos($url, $this->getAssetApiBaseUrl()) !== false) ) {
|
||||||
$requestArgs['headers']['Accept'] = 'application/octet-stream';
|
$requestArgs['headers']['Accept'] = 'application/octet-stream';
|
||||||
}
|
}
|
||||||
//Use Basic authentication, but only if the download is from our repository.
|
|
||||||
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
|
|
||||||
if ( $this->isAuthenticationEnabled() && (strpos($url, $repoApiBaseUrl)) === 0 ) {
|
|
||||||
$requestArgs['headers']['Authorization'] = $this->getAuthorizationHeader();
|
|
||||||
}
|
|
||||||
return $requestArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return parent::filterUpdateDownloadRequestArgs($requestArgs, $url);
|
||||||
* When following a redirect, the Requests library will automatically forward
|
|
||||||
* the authorization header to other hosts. We don't want that because it breaks
|
|
||||||
* AWS downloads and can leak authorization information.
|
|
||||||
*
|
|
||||||
* @param string $location
|
|
||||||
* @param array $headers
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
public function removeAuthHeaderFromRedirects(&$location, &$headers) {
|
|
||||||
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
|
|
||||||
if ( strpos($location, $repoApiBaseUrl) === 0 ) {
|
|
||||||
return; //This request is going to GitHub, so it's fine.
|
|
||||||
}
|
|
||||||
//Remove the header.
|
|
||||||
if ( isset($headers['Authorization']) ) {
|
|
||||||
unset($headers['Authorization']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate the value of the "Authorization" header.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
protected function getAuthorizationHeader() {
|
|
||||||
return 'Basic ' . base64_encode($this->userName . ':' . $this->accessToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
if ( !class_exists(GitLabApi::class, false) ):
|
if ( !class_exists(GitLabApi::class, false) ):
|
||||||
|
|
||||||
@@ -260,12 +260,7 @@ if ( !class_exists(GitLabApi::class, false) ):
|
|||||||
$baseUrl = $url;
|
$baseUrl = $url;
|
||||||
$url = $this->buildApiUrl($url, $queryParams);
|
$url = $this->buildApiUrl($url, $queryParams);
|
||||||
|
|
||||||
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
|
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
|
||||||
if ( !empty($this->httpFilterName) ) {
|
|
||||||
$options = apply_filters($this->httpFilterName, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = wp_remote_get($url, $options);
|
|
||||||
if ( is_wp_error($response) ) {
|
if ( is_wp_error($response) ) {
|
||||||
do_action('puc_api_error', $response, null, $url, $this->slug);
|
do_action('puc_api_error', $response, null, $url, $this->slug);
|
||||||
return $response;
|
return $response;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
|
||||||
|
|
||||||
if ( !class_exists(PluginUpdateChecker::class, false) ):
|
if ( !class_exists(PluginUpdateChecker::class, false) ):
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
if ( !class_exists(Reference::class, false) ):
|
if ( !class_exists(Reference::class, false) ):
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
if ( !trait_exists(ReleaseAssetSupport::class, false) ) :
|
if ( !trait_exists(ReleaseAssetSupport::class, false) ) :
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
if ( !trait_exists(ReleaseFilteringFeature::class, false) ) :
|
if ( !trait_exists(ReleaseFilteringFeature::class, false) ) :
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Theme;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Theme;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Utils;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Utils;
|
||||||
|
|
||||||
if ( !class_exists(ThemeUpdateChecker::class, false) ):
|
if ( !class_exists(ThemeUpdateChecker::class, false) ):
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
|
||||||
|
|
||||||
if ( !trait_exists(VcsCheckerMethods::class, false) ) :
|
if ( !trait_exists(VcsCheckerMethods::class, false) ) :
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
use WP_CLI;
|
use WP_CLI;
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ From the users' perspective, it works just like with plugins and themes hosted o
|
|||||||
Getting Started
|
Getting Started
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
*Note:* In each of the below examples, part of the instructions are to create an instance of the update checker class. It's recommended to do this either during the `plugins_loaded` action or outside of any hooks. If you do it only during an `admin_*` action, then updates will not be visible to a wide variety of WordPress maanagement tools; they will only be visible to logged-in users on dashboard pages.
|
*Note:* In each of the below examples, part of the instructions is to create an instance of the update checker class. It's recommended to do this either during the `plugins_loaded` action or outside of any hooks. If you do it only during an `admin_*` action, then updates will not be visible to a wide variety of WordPress management tools; they will only be visible to logged-in users on dashboard pages.
|
||||||
|
|
||||||
### Self-hosted Plugins and Themes
|
### Self-hosted Plugins and Themes
|
||||||
|
|
||||||
@@ -128,6 +128,11 @@ This library supports a couple of different ways to release updates on GitHub. P
|
|||||||
$myUpdateChecker->getVcsApi()->enableReleaseAssets();
|
$myUpdateChecker->getVcsApi()->enableReleaseAssets();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
By default, PUC will simply use the first available asset. You can pass a regular expression to `enableReleaseAssets()` to filter assets by name. For example:
|
||||||
|
```php
|
||||||
|
$myUpdateChecker->getVcsApi()->enableReleaseAssets('/\.zip($|[?&#])/i');
|
||||||
|
```
|
||||||
|
|
||||||
- **Tags**
|
- **Tags**
|
||||||
|
|
||||||
To release version 1.2.3, create a new Git tag named `v1.2.3` or `1.2.3`. That's it.
|
To release version 1.2.3, create a new Git tag named `v1.2.3` or `1.2.3`. That's it.
|
||||||
@@ -188,14 +193,12 @@ The library will pull update details from the following parts of a release/tag/b
|
|||||||
'unique-plugin-or-theme-slug'
|
'unique-plugin-or-theme-slug'
|
||||||
);
|
);
|
||||||
|
|
||||||
//Optional: If you're using a private repository, create an OAuth consumer
|
//Optional: If you're using a private repository, create an API token
|
||||||
//and set the authentication credentials like this:
|
//with the "read:repository:bitbucket" scope and set the authentication
|
||||||
//Note: For now you need to check "This is a private consumer" when
|
//credentials like this:
|
||||||
//creating the consumer to work around #134:
|
|
||||||
// https://github.com/YahnisElsts/plugin-update-checker/issues/134
|
|
||||||
$myUpdateChecker->setAuthentication(array(
|
$myUpdateChecker->setAuthentication(array(
|
||||||
'consumer_key' => '...',
|
'username' => 'example@example.com', //Your BitBucket email address.
|
||||||
'consumer_secret' => '...',
|
'api_token' => '...',
|
||||||
));
|
));
|
||||||
|
|
||||||
//Optional: Set the branch that contains the stable release.
|
//Optional: Set the branch that contains the stable release.
|
||||||
@@ -252,8 +255,8 @@ BitBucket doesn't have an equivalent to GitHub's releases, so the process is sli
|
|||||||
|
|
||||||
Alternatively, if you're using a self-hosted GitLab instance, initialize the update checker like this:
|
Alternatively, if you're using a self-hosted GitLab instance, initialize the update checker like this:
|
||||||
```php
|
```php
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\PluginUpdateChecker;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitLabApi;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitLabApi;
|
||||||
|
|
||||||
$myUpdateChecker = new PluginUpdateChecker(
|
$myUpdateChecker = new PluginUpdateChecker(
|
||||||
new GitLabApi('https://myserver.com/user-name/repo-name/'),
|
new GitLabApi('https://myserver.com/user-name/repo-name/'),
|
||||||
@@ -264,8 +267,8 @@ BitBucket doesn't have an equivalent to GitHub's releases, so the process is sli
|
|||||||
```
|
```
|
||||||
If you're using a self-hosted GitLab instance and [subgroups or nested groups](https://docs.gitlab.com/ce/user/group/subgroups/index.html), you have to tell the update checker which parts of the URL are subgroups:
|
If you're using a self-hosted GitLab instance and [subgroups or nested groups](https://docs.gitlab.com/ce/user/group/subgroups/index.html), you have to tell the update checker which parts of the URL are subgroups:
|
||||||
```php
|
```php
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\PluginUpdateChecker;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitLabApi;
|
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitLabApi;
|
||||||
|
|
||||||
$myUpdateChecker = new PluginUpdateChecker(
|
$myUpdateChecker = new PluginUpdateChecker(
|
||||||
new GitLabApi(
|
new GitLabApi(
|
||||||
@@ -347,14 +350,14 @@ Other classes have also been renamed, usually by simply removing the `Puc_vXpY_`
|
|||||||
| Old class name | New class name |
|
| Old class name | New class name |
|
||||||
|-------------------------------------|----------------------------------------------------------------|
|
|-------------------------------------|----------------------------------------------------------------|
|
||||||
| `Puc_v4_Factory` | `YahnisElsts\PluginUpdateChecker\v5\PucFactory` |
|
| `Puc_v4_Factory` | `YahnisElsts\PluginUpdateChecker\v5\PucFactory` |
|
||||||
| `Puc_v4p13_Factory` | `YahnisElsts\PluginUpdateChecker\v5p4\PucFactory` |
|
| `Puc_v4p13_Factory` | `YahnisElsts\PluginUpdateChecker\v5p7\PucFactory` |
|
||||||
| `Puc_v4p13_Plugin_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker` |
|
| `Puc_v4p13_Plugin_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker` |
|
||||||
| `Puc_v4p13_Theme_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Theme\UpdateChecker` |
|
| `Puc_v4p13_Theme_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Theme\UpdateChecker` |
|
||||||
| `Puc_v4p13_Vcs_PluginUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker` |
|
| `Puc_v4p13_Vcs_PluginUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\PluginUpdateChecker` |
|
||||||
| `Puc_v4p13_Vcs_ThemeUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\ThemeUpdateChecker` |
|
| `Puc_v4p13_Vcs_ThemeUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\ThemeUpdateChecker` |
|
||||||
| `Puc_v4p13_Vcs_GitHubApi` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitHubApi` |
|
| `Puc_v4p13_Vcs_GitHubApi` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitHubApi` |
|
||||||
| `Puc_v4p13_Vcs_GitLabApi` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitLabApi` |
|
| `Puc_v4p13_Vcs_GitLabApi` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitLabApi` |
|
||||||
| `Puc_v4p13_Vcs_BitBucketApi` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\BitBucketApi` |
|
| `Puc_v4p13_Vcs_BitBucketApi` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\BitBucketApi` |
|
||||||
|
|
||||||
License Management
|
License Management
|
||||||
------------------
|
------------------
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
"ext-json": "*"
|
"ext-json": "*"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"files": ["load-v5p4.php"]
|
"files": ["load-v5p7.php"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: plugin-update-checker\n"
|
||||||
|
"POT-Creation-Date: 2022-07-29 15:34+0300\n"
|
||||||
|
"PO-Revision-Date: 2024-05-09 22:22+0000\n"
|
||||||
|
"Last-Translator: theogk\n"
|
||||||
|
"Language-Team: Ελληνικά\n"
|
||||||
|
"Language: el\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||||
|
"X-Generator: Loco https://localise.biz/\n"
|
||||||
|
"X-Poedit-Basepath: ..\n"
|
||||||
|
"X-Poedit-SourceCharset: UTF-8\n"
|
||||||
|
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
|
||||||
|
"X-Poedit-SearchPath-0: .\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"X-Loco-Version: 2.6.9; wp-6.5.3"
|
||||||
|
|
||||||
|
#: Puc/v5p4/Plugin/Ui.php:128
|
||||||
|
msgid "Check for updates"
|
||||||
|
msgstr "Έλεγχος για ενημερώσεις"
|
||||||
|
|
||||||
|
#: Puc/v5p4/Plugin/Ui.php:214
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "The %s plugin is up to date."
|
||||||
|
msgstr "Το πρόσθετο %s είναι ενημερωμένο."
|
||||||
|
|
||||||
|
#: Puc/v5p4/Plugin/Ui.php:216
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "A new version of the %s plugin is available."
|
||||||
|
msgstr "Μία νέα έκδοση είναι διαθέσιμη για το πρόσθετο %s."
|
||||||
|
|
||||||
|
#: Puc/v5p4/Plugin/Ui.php:218
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "Could not determine if updates are available for %s."
|
||||||
|
msgstr ""
|
||||||
|
"Δεν ήταν εφικτό να εκτελεστεί ο έλεγχος για νέες ενημερώσεις για το πρόσθετο "
|
||||||
|
"%s."
|
||||||
|
|
||||||
|
#: Puc/v5p4/Plugin/Ui.php:224
|
||||||
|
#, php-format
|
||||||
|
msgid "Unknown update checker status \"%s\""
|
||||||
|
msgstr "Άγνωστο πρόβλημα του ενημερωτή προσθέτων \"%s\""
|
||||||
|
|
||||||
|
#: Puc/v5p4/Vcs/PluginUpdateChecker.php:100
|
||||||
|
msgid "There is no changelog available."
|
||||||
|
msgstr "Δεν υπάρχει διαθέσιμο αρχείο αλλαγών."
|
||||||
@@ -1,38 +1,50 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: plugin-update-checker\n"
|
"Project-Id-Version: plugin-update-checker\n"
|
||||||
"POT-Creation-Date: 2016-02-17 14:21+0100\n"
|
"POT-Creation-Date: 2025-06-12 23:40+0100\n"
|
||||||
"PO-Revision-Date: 2016-10-28 14:30+0330\n"
|
"PO-Revision-Date: 2025-06-12 23:49+0100\n"
|
||||||
"Last-Translator: studio RVOLA <hello@rvola.com>\n"
|
"Last-Translator: Pro Style <info@prostyle.ir>\n"
|
||||||
"Language-Team: Pro Style <info@prostyle.ir>\n"
|
"Language-Team: Alex Javadi <alex@aljm.org>\n"
|
||||||
"Language: fa_IR\n"
|
"Language: fa_IR\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"X-Generator: Poedit 1.8.8\n"
|
|
||||||
"X-Poedit-Basepath: ..\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
|
"X-Generator: Poedit 3.6\n"
|
||||||
|
"X-Poedit-Basepath: ..\n"
|
||||||
"X-Poedit-SourceCharset: UTF-8\n"
|
"X-Poedit-SourceCharset: UTF-8\n"
|
||||||
"X-Poedit-KeywordsList: __;_e\n"
|
"X-Poedit-KeywordsList: __;_e\n"
|
||||||
"X-Poedit-SearchPath-0: .\n"
|
"X-Poedit-SearchPath-0: .\n"
|
||||||
|
|
||||||
#: github-checker.php:120
|
#: Puc/v5p6/Plugin/Ui.php:56
|
||||||
msgid "There is no changelog available."
|
msgid "View details"
|
||||||
msgstr "شرحی برای تغییرات یافت نشد"
|
msgstr "مشاهده جزئیات"
|
||||||
|
|
||||||
#: plugin-update-checker.php:637
|
#: Puc/v5p6/Plugin/Ui.php:79
|
||||||
|
#, php-format
|
||||||
|
msgid "More information about %s"
|
||||||
|
msgstr "اطلاعات بیشتر درباره %s"
|
||||||
|
|
||||||
|
# It had some “potential” grammar issues and also didn’t sound native.
|
||||||
|
# P.S. I know the current translation is literally “Checking for new updates”, however, I thought it might sound more natural and known to others.
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:130
|
||||||
msgid "Check for updates"
|
msgid "Check for updates"
|
||||||
msgstr "بررسی برای بروزرسانی "
|
msgstr "بررسی بروزرسانی جدید"
|
||||||
|
|
||||||
#: plugin-update-checker.php:681
|
# The word “ناشناخته” is seems to be translated directly from the word (Un-known), rather than checking for the context.
|
||||||
msgid "This plugin is up to date."
|
# I think “نامشخص” (unknown) might be a suitable version in this scenario.
|
||||||
msgstr "شما از آخرین نسخه استفاده میکنید . بهروز باشید"
|
#: Puc/v5p6/Plugin/Ui.php:227
|
||||||
|
|
||||||
#: plugin-update-checker.php:683
|
|
||||||
msgid "A new version of this plugin is available."
|
|
||||||
msgstr "نسخه جدیدی برای افزونه ارائه شده است ."
|
|
||||||
|
|
||||||
#: plugin-update-checker.php:685
|
|
||||||
#, php-format
|
#, php-format
|
||||||
msgid "Unknown update checker status \"%s\""
|
msgid "Unknown update checker status \"%s\""
|
||||||
msgstr "وضعیت ناشناخته برای بروزرسانی \"%s\""
|
msgstr "وضعیت نامشخص برای بروزرسانی \"%s\""
|
||||||
|
|
||||||
|
# The previous translation was okay, however, it didn’t sound native to me.
|
||||||
|
#: Puc/v5p6/Vcs/PluginUpdateChecker.php:113
|
||||||
|
msgid "There is no changelog available."
|
||||||
|
msgstr "آخرین تغییراتی یافت نشد."
|
||||||
|
|
||||||
|
#~ msgid "This plugin is up to date."
|
||||||
|
#~ msgstr "شما از آخرین نسخه استفاده میکنید . بهروز باشید"
|
||||||
|
|
||||||
|
#~ msgid "A new version of this plugin is available."
|
||||||
|
#~ msgstr "نسخه جدیدی برای افزونه ارائه شده است ."
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: plugin-update-checker\n"
|
||||||
|
"POT-Creation-Date: 2024-12-09 11:45+0100\n"
|
||||||
|
"PO-Revision-Date: 2024-12-09 12:20+0100\n"
|
||||||
|
"Last-Translator: Aleksandar Urošević <urke.kg@gmail.com>\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Language: sr_RS\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
"X-Generator: Poedit 2.4.3\n"
|
||||||
|
"X-Poedit-Basepath: ..\n"
|
||||||
|
"X-Poedit-SourceCharset: UTF-8\n"
|
||||||
|
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
|
||||||
|
"X-Poedit-SearchPath-0: .\n"
|
||||||
|
|
||||||
|
#: Puc/v5p5/Plugin/Ui.php:56
|
||||||
|
msgid "View details"
|
||||||
|
msgstr "Види детаље"
|
||||||
|
|
||||||
|
#: Puc/v5p5/Plugin/Ui.php:79
|
||||||
|
#, php-format
|
||||||
|
msgid "More information about %s"
|
||||||
|
msgstr "Више информација о %s"
|
||||||
|
|
||||||
|
#: Puc/v5p5/Plugin/Ui.php:130
|
||||||
|
msgid "Check for updates"
|
||||||
|
msgstr "Провера ажурирања"
|
||||||
|
|
||||||
|
#: Puc/v5p5/Plugin/Ui.php:217
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "The %s plugin is up to date."
|
||||||
|
msgstr "Додатак %s је у најновијем издању."
|
||||||
|
|
||||||
|
#: Puc/v5p5/Plugin/Ui.php:219
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "A new version of the %s plugin is available."
|
||||||
|
msgstr "Доступно је ново издање за %s."
|
||||||
|
|
||||||
|
#: Puc/v5p5/Plugin/Ui.php:221
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "Could not determine if updates are available for %s."
|
||||||
|
msgstr "Није могуће утврдити да ли су доступне исправке за %s."
|
||||||
|
|
||||||
|
#: Puc/v5p5/Plugin/Ui.php:227
|
||||||
|
#, php-format
|
||||||
|
msgid "Unknown update checker status \"%s\""
|
||||||
|
msgstr "Непознат статус провере ажурирања \"%s\""
|
||||||
|
|
||||||
|
#: Puc/v5p5/Vcs/PluginUpdateChecker.php:113
|
||||||
|
msgid "There is no changelog available."
|
||||||
|
msgstr "Белешке о изменама нису доступне."
|
||||||
@@ -1,57 +1,57 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: plugin-update-checker\n"
|
"Project-Id-Version: plugin-update-checker\n"
|
||||||
"POT-Creation-Date: 2022-01-29 12:09+0800\n"
|
"POT-Creation-Date: 2025-11-21 10:40+0800\n"
|
||||||
"PO-Revision-Date: 2022-01-29 12:10+0800\n"
|
"PO-Revision-Date: 2025-11-21 10:40+0800\n"
|
||||||
"Last-Translator: Seaton Jiang <hi@seatonjiang.com>\n"
|
"Last-Translator: Seaton Jiang <hi@seatonjiang.com>\n"
|
||||||
"Language-Team: \n"
|
"Language-Team: \n"
|
||||||
"Language: zh_CN\n"
|
"Language: zh_CN\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"X-Generator: Poedit 2.4.3\n"
|
|
||||||
"X-Poedit-Basepath: ..\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
"X-Generator: Poedit 3.8\n"
|
||||||
|
"X-Poedit-Basepath: ..\n"
|
||||||
"X-Poedit-SourceCharset: UTF-8\n"
|
"X-Poedit-SourceCharset: UTF-8\n"
|
||||||
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
|
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
|
||||||
"X-Poedit-SearchPath-0: .\n"
|
"X-Poedit-SearchPath-0: .\n"
|
||||||
|
|
||||||
#: Puc/v4p11/Plugin/Ui.php:54
|
#: Puc/v5p6/Plugin/Ui.php:56
|
||||||
msgid "View details"
|
msgid "View details"
|
||||||
msgstr "查看详情"
|
msgstr "查看详情"
|
||||||
|
|
||||||
#: Puc/v4p11/Plugin/Ui.php:77
|
#: Puc/v5p6/Plugin/Ui.php:79
|
||||||
#, php-format
|
#, php-format
|
||||||
msgid "More information about %s"
|
msgid "More information about %s"
|
||||||
msgstr "%s 的更多信息"
|
msgstr "%s 的更多信息"
|
||||||
|
|
||||||
#: Puc/v4p11/Plugin/Ui.php:128
|
#: Puc/v5p6/Plugin/Ui.php:130
|
||||||
msgid "Check for updates"
|
msgid "Check for updates"
|
||||||
msgstr "检查更新"
|
msgstr "检查更新"
|
||||||
|
|
||||||
#: Puc/v4p11/Plugin/Ui.php:214
|
#: Puc/v5p6/Plugin/Ui.php:217
|
||||||
#, php-format
|
#, php-format
|
||||||
msgctxt "the plugin title"
|
msgctxt "the plugin title"
|
||||||
msgid "The %s plugin is up to date."
|
msgid "The %s plugin is up to date."
|
||||||
msgstr "%s 目前是最新版本。"
|
msgstr "%s 目前是最新版本。"
|
||||||
|
|
||||||
#: Puc/v4p11/Plugin/Ui.php:216
|
#: Puc/v5p6/Plugin/Ui.php:219
|
||||||
#, php-format
|
#, php-format
|
||||||
msgctxt "the plugin title"
|
msgctxt "the plugin title"
|
||||||
msgid "A new version of the %s plugin is available."
|
msgid "A new version of the %s plugin is available."
|
||||||
msgstr "%s 当前有可用的更新。"
|
msgstr "%s 当前有可用的更新。"
|
||||||
|
|
||||||
#: Puc/v4p11/Plugin/Ui.php:218
|
#: Puc/v5p6/Plugin/Ui.php:221
|
||||||
#, php-format
|
#, php-format
|
||||||
msgctxt "the plugin title"
|
msgctxt "the plugin title"
|
||||||
msgid "Could not determine if updates are available for %s."
|
msgid "Could not determine if updates are available for %s."
|
||||||
msgstr "%s 无法确定是否有可用的更新。"
|
msgstr "%s 无法确定是否有可用的更新。"
|
||||||
|
|
||||||
#: Puc/v4p11/Plugin/Ui.php:224
|
#: Puc/v5p6/Plugin/Ui.php:227
|
||||||
#, php-format
|
#, php-format
|
||||||
msgid "Unknown update checker status \"%s\""
|
msgid "Unknown update checker status \"%s\""
|
||||||
msgstr "未知的更新检查状态:%s"
|
msgstr "未知的更新检查状态:%s"
|
||||||
|
|
||||||
#: Puc/v4p11/Vcs/PluginUpdateChecker.php:100
|
#: Puc/v5p6/Vcs/PluginUpdateChecker.php:113
|
||||||
msgid "There is no changelog available."
|
msgid "There is no changelog available."
|
||||||
msgstr "没有可用的更新日志。"
|
msgstr "没有可用的更新日志。"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Blank Plugin POT Template
|
||||||
|
# Copyright 2025 ...
|
||||||
|
# This file is distributed under the GNU General Public License v3 or later.
|
||||||
|
#, fuzzy
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Plugin Update Checker\n"
|
||||||
|
"Report-Msgid-Bugs-To: Alex Lion <learnwithalex@gmail.com>\n"
|
||||||
|
"POT-Creation-Date: 2025-09-19 14:05-0700\n"
|
||||||
|
"PO-Revision-Date: \n"
|
||||||
|
"Last-Translator: Alex Lion <learnwithalex@gmail.com>\n"
|
||||||
|
"Language-Team: Alex Lion <learnwithalex@gmail.com>\n"
|
||||||
|
"Language: zh_TW\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
"X-Poedit-WPHeader: plugin-update-checker.php\n"
|
||||||
|
"X-Textdomain-Support: yesX-Generator: Poedit 1.6.4\n"
|
||||||
|
"X-Poedit-SourceCharset: UTF-8\n"
|
||||||
|
"X-Poedit-KeywordsList: "
|
||||||
|
"__;_e;esc_html_e;esc_html_x:1,2c;esc_html__;esc_attr_e;esc_attr_x:1,2c;esc_attr__;_ex:1,2c;_nx:4c,1,2;_nx_noop:4c,1,2;_x:1,2c;_n:1,2;_n_noop:1,2;__ngettext:1,2;__ngettext_noop:1,2;_c,_nc:4c,1,2\n"
|
||||||
|
"X-Poedit-Basepath: ..\n"
|
||||||
|
"X-Poedit-Bookmarks: \n"
|
||||||
|
"X-Generator: Poedit 3.7\n"
|
||||||
|
"X-Poedit-SearchPath-0: .\n"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:56
|
||||||
|
msgid "View details"
|
||||||
|
msgstr "檢視詳細資料"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:79
|
||||||
|
#, php-format
|
||||||
|
msgid "More information about %s"
|
||||||
|
msgstr "進一步了解 %s 的相關資訊"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:130
|
||||||
|
msgid "Check for updates"
|
||||||
|
msgstr "檢查更新"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:217
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "The %s plugin is up to date."
|
||||||
|
msgstr "%s 外掛已為最新版本。"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:219
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "A new version of the %s plugin is available."
|
||||||
|
msgstr "%s 外掛已有新版本可供更新。"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:221
|
||||||
|
#, php-format
|
||||||
|
msgctxt "the plugin title"
|
||||||
|
msgid "Could not determine if updates are available for %s."
|
||||||
|
msgstr "無法確定 %s 是否有可用的更新。"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Plugin/Ui.php:227
|
||||||
|
#, php-format
|
||||||
|
msgid "Unknown update checker status \"%s\""
|
||||||
|
msgstr "未知的更新檢查程式狀態: %s"
|
||||||
|
|
||||||
|
#: Puc/v5p6/Vcs/PluginUpdateChecker.php:113
|
||||||
|
msgid "There is no changelog available."
|
||||||
|
msgstr "目前沒有可供檢閱的變更記錄。"
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: plugin-update-checker\n"
|
"Project-Id-Version: plugin-update-checker\n"
|
||||||
"POT-Creation-Date: 2022-07-29 15:34+0300\n"
|
"POT-Creation-Date: 2025-05-20 15:27+0300\n"
|
||||||
"PO-Revision-Date: 2016-01-10 20:59+0100\n"
|
"PO-Revision-Date: 2016-01-10 20:59+0100\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: \n"
|
"Language-Team: \n"
|
||||||
@@ -11,39 +11,39 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
"X-Generator: Poedit 3.1.1\n"
|
"X-Generator: Poedit 3.6\n"
|
||||||
"X-Poedit-Basepath: ..\n"
|
"X-Poedit-Basepath: ..\n"
|
||||||
"X-Poedit-SourceCharset: UTF-8\n"
|
"X-Poedit-SourceCharset: UTF-8\n"
|
||||||
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
|
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
|
||||||
"X-Poedit-SearchPath-0: .\n"
|
"X-Poedit-SearchPath-0: .\n"
|
||||||
|
|
||||||
#: Puc/v5p4/Plugin/Ui.php:128
|
#: Puc/v5p7/Plugin/Ui.php:130
|
||||||
msgid "Check for updates"
|
msgid "Check for updates"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: Puc/v5p4/Plugin/Ui.php:214
|
#: Puc/v5p7/Plugin/Ui.php:217
|
||||||
#, php-format
|
#, php-format
|
||||||
msgctxt "the plugin title"
|
msgctxt "the plugin title"
|
||||||
msgid "The %s plugin is up to date."
|
msgid "The %s plugin is up to date."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: Puc/v5p4/Plugin/Ui.php:216
|
#: Puc/v5p7/Plugin/Ui.php:219
|
||||||
#, php-format
|
#, php-format
|
||||||
msgctxt "the plugin title"
|
msgctxt "the plugin title"
|
||||||
msgid "A new version of the %s plugin is available."
|
msgid "A new version of the %s plugin is available."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: Puc/v5p4/Plugin/Ui.php:218
|
#: Puc/v5p7/Plugin/Ui.php:221
|
||||||
#, php-format
|
#, php-format
|
||||||
msgctxt "the plugin title"
|
msgctxt "the plugin title"
|
||||||
msgid "Could not determine if updates are available for %s."
|
msgid "Could not determine if updates are available for %s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: Puc/v5p4/Plugin/Ui.php:224
|
#: Puc/v5p7/Plugin/Ui.php:227
|
||||||
#, php-format
|
#, php-format
|
||||||
msgid "Unknown update checker status \"%s\""
|
msgid "Unknown update checker status \"%s\""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: Puc/v5p4/Vcs/PluginUpdateChecker.php:100
|
#: Puc/v5p7/Vcs/PluginUpdateChecker.php:113
|
||||||
msgid "There is no changelog available."
|
msgid "There is no changelog available."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace YahnisElsts\PluginUpdateChecker\v5p4;
|
namespace YahnisElsts\PluginUpdateChecker\v5p7;
|
||||||
|
|
||||||
use YahnisElsts\PluginUpdateChecker\v5\PucFactory as MajorFactory;
|
use YahnisElsts\PluginUpdateChecker\v5\PucFactory as MajorFactory;
|
||||||
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory as MinorFactory;
|
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory as MinorFactory;
|
||||||
|
|
||||||
require __DIR__ . '/Puc/v5p4/Autoloader.php';
|
require __DIR__ . '/Puc/v5p7/Autoloader.php';
|
||||||
new Autoloader();
|
new Autoloader();
|
||||||
|
|
||||||
require __DIR__ . '/Puc/v5p4/PucFactory.php';
|
require __DIR__ . '/Puc/v5p7/PucFactory.php';
|
||||||
require __DIR__ . '/Puc/v5/PucFactory.php';
|
require __DIR__ . '/Puc/v5/PucFactory.php';
|
||||||
|
|
||||||
//Register classes defined in this version with the factory.
|
//Register classes defined in this version with the factory.
|
||||||
@@ -26,9 +26,9 @@ foreach (
|
|||||||
)
|
)
|
||||||
as $pucGeneralClass => $pucVersionedClass
|
as $pucGeneralClass => $pucVersionedClass
|
||||||
) {
|
) {
|
||||||
MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.4');
|
MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7');
|
||||||
//Also add it to the minor-version factory in case the major-version factory
|
//Also add it to the minor-version factory in case the major-version factory
|
||||||
//was already defined by another, older version of the update checker.
|
//was already defined by another, older version of the update checker.
|
||||||
MinorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.4');
|
MinorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Plugin Update Checker Library 5.4
|
* Plugin Update Checker Library 5.7
|
||||||
* http://w-shadow.com/
|
* http://w-shadow.com/
|
||||||
*
|
*
|
||||||
* Copyright 2024 Janis Elsts
|
* Copyright 2026 Janis Elsts
|
||||||
* Released under the MIT license. See license.txt for details.
|
* Released under the MIT license. See license.txt for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require dirname(__FILE__) . '/load-v5p4.php';
|
require dirname(__FILE__) . '/load-v5p7.php';
|
||||||
@@ -648,7 +648,7 @@ class Parsedown
|
|||||||
#
|
#
|
||||||
# Setext
|
# Setext
|
||||||
|
|
||||||
protected function blockSetextHeader($Line, array $Block = null)
|
protected function blockSetextHeader($Line, $Block = null)
|
||||||
{
|
{
|
||||||
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
|
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
|
||||||
{
|
{
|
||||||
@@ -786,7 +786,7 @@ class Parsedown
|
|||||||
#
|
#
|
||||||
# Table
|
# Table
|
||||||
|
|
||||||
protected function blockTable($Line, array $Block = null)
|
protected function blockTable($Line, $Block = null)
|
||||||
{
|
{
|
||||||
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
|
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -186,7 +186,13 @@ class Gitea_Provider implements Repository_Provider {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$release = $body[0];
|
// Gitea's /releases/latest returns a single release OBJECT (associative array),
|
||||||
|
// not a list. Some endpoints / older Gitea versions may return a list; handle both.
|
||||||
|
$release = isset( $body['tag_name'] ) ? $body : ( $body[0] ?? null );
|
||||||
|
if ( ! is_array( $release ) || empty( $release['tag_name'] ?? '' ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
set_transient( $cache_key, $release, HOUR_IN_SECONDS );
|
set_transient( $cache_key, $release, HOUR_IN_SECONDS );
|
||||||
return $release;
|
return $release;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Release Workflow Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a GitHub Actions workflow that, on every `v*` tag push, builds `projects-portfolio-v<version>.zip` from an allowlist of runtime files and publishes it both as a workflow artifact and as an attachment to a matching GitHub release.
|
||||||
|
|
||||||
|
**Architecture:** Single workflow file at `.github/workflows/release.yml`. One job (`release`) on `ubuntu-latest` performs: checkout → bash build step (allowlisted `cp -r` into a staging dir, then `zip -r`) → `actions/upload-artifact@v4` → `softprops/action-gh-release@v2`. Uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission.
|
||||||
|
|
||||||
|
**Tech Stack:** GitHub Actions YAML, bash, `zip` CLI (preinstalled on `ubuntu-latest`), `actions/checkout@v4`, `actions/upload-artifact@v4`, `softprops/action-gh-release@v2`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Workflow file lives at `.github/workflows/release.yml`.
|
||||||
|
- Trigger: `on.push.tags: ['v*']`. No `workflow_dispatch`.
|
||||||
|
- Permission: `contents: write`.
|
||||||
|
- Zip filename: `projects-portfolio-v<version>.zip` where `<version>` is `GITHUB_REF_NAME` with leading `v` stripped.
|
||||||
|
- Zip internal layout: `projects-portfolio/` directory at zip root, with `projects-portfolio.php` inside it (WordPress install-from-zip convention).
|
||||||
|
- Allowlisted source paths copied into staging: `projects-portfolio.php`, `README.md`, `LICENSE`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`.
|
||||||
|
- Excluded paths (no `cp` of these): `composer.json`, `composer.lock`, `vendor/`, `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`.
|
||||||
|
- Build step uses `set -euo pipefail` and an `unzip -l "$OUT"` debug print at the end so the action log self-documents the contents.
|
||||||
|
- Artifact upload uses `if-no-files-found: error` so a missing zip fails the job.
|
||||||
|
- `softprops/action-gh-release@v2` is given the tag name, release name = tag name, `generate_release_notes: true`, and `files:` pointing at the zip.
|
||||||
|
- README gets a "Building a Release" section after "Connect Your Gitea Repo".
|
||||||
|
- First release is `v1.1.1`: implementation includes a version bump from `1.1.0` → `1.1.1` (header `Version:` + `PROJECTS_PORTFOLIO_VERSION` constant).
|
||||||
|
- No PHP files outside of the version bump are touched. The PHPUnit suite is unaffected.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `.github/workflows/release.yml` — the workflow.
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `README.md` — adds a "Building a Release" section after the existing "Connect Your Gitea Repo" section.
|
||||||
|
- `projects-portfolio.php` — bumps header `Version:` and `PROJECTS_PORTFOLIO_VERSION` from `1.1.0` to `1.1.1` as part of the first-release rollout.
|
||||||
|
|
||||||
|
No other files touched. No tests added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add the release workflow file
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/release.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: a workflow that triggers on `v*` tag push and produces a release zip + GitHub release.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `.github/workflows/release.yml`**
|
||||||
|
|
||||||
|
Create the directory if missing, then create the file with this exact content:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build & publish plugin zip
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build plugin zip
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
STAGE="dist/staging"
|
||||||
|
OUT="dist/projects-portfolio-v${VERSION}.zip"
|
||||||
|
|
||||||
|
rm -rf dist
|
||||||
|
mkdir -p "$STAGE/projects-portfolio"
|
||||||
|
|
||||||
|
cp projects-portfolio.php "$STAGE/projects-portfolio/"
|
||||||
|
cp README.md "$STAGE/projects-portfolio/"
|
||||||
|
cp LICENSE "$STAGE/projects-portfolio/"
|
||||||
|
cp -r admin/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r assets/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r includes/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r languages/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r templates/ "$STAGE/projects-portfolio/"
|
||||||
|
|
||||||
|
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
|
||||||
|
|
||||||
|
echo "Built $OUT"
|
||||||
|
unzip -l "$OUT"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: projects-portfolio-v${{ github.ref_name }}
|
||||||
|
path: dist/projects-portfolio-*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
name: ${{ github.ref_name }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: dist/projects-portfolio-*.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate YAML syntax locally**
|
||||||
|
|
||||||
|
Run from the project root:
|
||||||
|
```bash
|
||||||
|
php -r "require 'vendor/autoload.php';" 2>/dev/null || true
|
||||||
|
# PHP doesn't have a built-in YAML parser; use Python's if available
|
||||||
|
python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml').read()); print('OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `python` is not on PATH, use any other YAML validator you have. If none is available, skip this step — GitHub's own workflow validation will catch syntax errors on the next push.
|
||||||
|
|
||||||
|
Expected (if `python` is available): `OK` on stdout, exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/release.yml
|
||||||
|
git commit -m "Add GitHub Actions release workflow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add "Building a Release" section to README
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: a "Building a Release" section placed after the "Connect Your Gitea Repo" section (which was added in the previous feature).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Insert the new section**
|
||||||
|
|
||||||
|
Find the existing `## Connect Your Gitea Repo` section in `README.md`. Locate the heading `## Building a Release` (it does not yet exist; that's the insertion target).
|
||||||
|
|
||||||
|
Insert the following block directly after the closing line of the Gitea repo subsection (`Paste it into the plugin settings screen under **Gitea API Token**.`) and before any subsequent `## ` heading:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
|
||||||
|
## Building a Release
|
||||||
|
|
||||||
|
Releases are automated via GitHub Actions. To cut a new release:
|
||||||
|
|
||||||
|
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
|
||||||
|
2. Commit and push to `main`.
|
||||||
|
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
|
||||||
|
4. Push the tag: `git push origin v1.1.0`.
|
||||||
|
|
||||||
|
The `.github/workflows/release.yml` workflow runs and:
|
||||||
|
|
||||||
|
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
|
||||||
|
- Uploads the zip as a workflow artifact.
|
||||||
|
- Creates (or updates) the matching GitHub Release with the zip attached.
|
||||||
|
|
||||||
|
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify with grep**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -n "Building a Release" README.md
|
||||||
|
```
|
||||||
|
Expected: one matching line, located after the "Connect Your Gitea Repo" section.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "Document automated release process in README"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Bump version to 1.1.1 for first automated release
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `projects-portfolio.php` — header `Version:` and `PROJECTS_PORTFOLIO_VERSION` constant.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: in-tree version `1.1.1`, header comment line `* Version: 1.1.1`, constant `define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the plugin header**
|
||||||
|
|
||||||
|
In `projects-portfolio.php`, in the file header comment block near the top, change:
|
||||||
|
```
|
||||||
|
* Version: 1.1.0
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```
|
||||||
|
* Version: 1.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the version constant**
|
||||||
|
|
||||||
|
In `projects-portfolio.php`, find the line:
|
||||||
|
```php
|
||||||
|
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
|
||||||
|
```
|
||||||
|
Replace with:
|
||||||
|
```php
|
||||||
|
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the test suite to confirm no regression**
|
||||||
|
|
||||||
|
Run: `php vendor/bin/phpunit`
|
||||||
|
Expected: `OK (24 tests, 45 assertions)` (same as before — no PHP behavior change).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify with grep**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -n "Version:" projects-portfolio.php | head -1
|
||||||
|
grep -n "PROJECTS_PORTFOLIO_VERSION" projects-portfolio.php
|
||||||
|
```
|
||||||
|
Expected output:
|
||||||
|
- First grep: a line containing `* Version: 1.1.1`.
|
||||||
|
- Second grep: two lines — the `define(...)` and (possibly) usage sites. The `define` line must show `'1.1.1'`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add projects-portfolio.php
|
||||||
|
git commit -m "Bump version to 1.1.1 for first automated release"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Final verification
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm all three commits landed**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
Expected (most recent at top): three commits from this plan are visible:
|
||||||
|
- "Bump version to 1.1.1 for first automated release"
|
||||||
|
- "Document automated release process in README"
|
||||||
|
- "Add GitHub Actions release workflow"
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm working tree is clean**
|
||||||
|
|
||||||
|
Run: `git status`
|
||||||
|
Expected: `nothing to commit, working tree clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full PHPUnit suite**
|
||||||
|
|
||||||
|
Run: `php vendor/bin/phpunit`
|
||||||
|
Expected: `OK (24 tests, 45 assertions)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm workflow file content**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
head -30 .github/workflows/release.yml
|
||||||
|
```
|
||||||
|
Expected: matches the YAML produced in Task 1, with `name: Release`, `on.push.tags: ['v*']`, `permissions.contents: write`, and the `release` job.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Confirm README has the new section**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -c "Building a Release" README.md
|
||||||
|
```
|
||||||
|
Expected: `1`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Note (do NOT execute): the first automated release**
|
||||||
|
|
||||||
|
The user will tag `v1.1.1` and push the tag themselves, which triggers the workflow. The workflow file is in place; the in-tree version is bumped; the first release will produce a zip with this code. The implementer does NOT push the tag or the branch — that's the user's call (their existing git history is ahead of `origin/main`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Final commit if anything changed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
# If clean, skip. Otherwise:
|
||||||
|
git add -A
|
||||||
|
git commit -m "Final verification fixes"
|
||||||
|
```
|
||||||
@@ -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.1.0
|
* Version: 1.2.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+
|
||||||
@@ -34,12 +34,12 @@ $myUpdateChecker = PucFactory::buildUpdateChecker(
|
|||||||
__FILE__,
|
__FILE__,
|
||||||
'projects-portfolio'
|
'projects-portfolio'
|
||||||
);
|
);
|
||||||
|
// Note: PUC's setBranch() is only available when the host is recognized as a
|
||||||
// Set the branch that contains the stable release.
|
// VCS provider (GitHub, GitLab, Bitbucket). The Gitea URL here falls back to
|
||||||
$myUpdateChecker->setBranch( 'main' );
|
// PUC's plain JSON metadata mode, so we skip the call rather than throw.
|
||||||
|
|
||||||
// Current plugin version.
|
// Current plugin version.
|
||||||
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
|
define( 'PROJECTS_PORTFOLIO_VERSION', '1.2.0' );
|
||||||
|
|
||||||
// Add the required files.
|
// Add the required files.
|
||||||
require 'admin/admin-settings.php';
|
require 'admin/admin-settings.php';
|
||||||
@@ -112,7 +112,10 @@ function projects_portfolio_handle_download_redirect() {
|
|||||||
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 release ZIP on the project's host.
|
// Redirect to the release ZIP on the project's host.
|
||||||
wp_safe_redirect( esc_url_raw( $download_url ) );
|
// Use wp_redirect() (not wp_safe_redirect()) because the destination
|
||||||
|
// is an external host the user has explicitly configured — wp_safe_redirect
|
||||||
|
// would reject it and fall back to admin_url().
|
||||||
|
wp_redirect( esc_url_raw( $download_url ), 302 ); // phpcs:ignore
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
wp_die(
|
wp_die(
|
||||||
@@ -268,16 +271,6 @@ function projects_portfolio_enqueue_project_styles() {
|
|||||||
wp_enqueue_style( 'projects-wp-styles' );
|
wp_enqueue_style( 'projects-wp-styles' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( is_singular( 'projects' ) ) {
|
|
||||||
wp_enqueue_script(
|
|
||||||
'projects-wp-buttons',
|
|
||||||
plugin_dir_url( __FILE__ ) . 'assets/js/buttons.js',
|
|
||||||
array(),
|
|
||||||
PROJECTS_PORTFOLIO_VERSION,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
add_action( 'wp_enqueue_scripts', 'projects_portfolio_enqueue_project_styles' );
|
add_action( 'wp_enqueue_scripts', 'projects_portfolio_enqueue_project_styles' );
|
||||||
|
|
||||||
@@ -351,39 +344,47 @@ function projects_portfolio_social_sharing_buttons( $project_id ) {
|
|||||||
|
|
||||||
$html = '<div class="project-social-sharing">';
|
$html = '<div class="project-social-sharing">';
|
||||||
|
|
||||||
|
$svg_icon = function ( string $name, string $file ) use ( &$html ) {
|
||||||
|
// Render each social icon as an <img> referencing a real .svg file
|
||||||
|
// shipped under assets/icons/. This bypasses any kses data: URI stripping
|
||||||
|
// and ensures the icon loads as a normal image via standard img rules.
|
||||||
|
$src = plugins_url( 'assets/icons/' . $file, __FILE__ );
|
||||||
|
$html .= '<img src="' . esc_url( $src ) . '" alt="' . esc_attr( $name ) . '" width="20" height="20" style="width:20px;height:20px;display:inline-block;vertical-align:middle;" />';
|
||||||
|
};
|
||||||
|
|
||||||
// Facebook.
|
// Facebook.
|
||||||
$html .= '<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
$html .= '<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
||||||
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-facebook" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="0" y="0" width="24" height="24" fill="none"/><path d="M7 10v-3a1 1 0 0 1 1 -1h3v-4h4v4h3a1 1 0 0 1 1 1v3h-4v10h-4v-10h-3" /></svg>';
|
$svg_icon( 'Facebook', 'facebook.svg' );
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
|
|
||||||
// X (Twitter).
|
// X (Twitter).
|
||||||
$html .= '<a href="https://twitter.com/intent/tweet?text=' . $title . '&url=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
$html .= '<a href="https://twitter.com/intent/tweet?text=' . $title . '&url=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
||||||
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-x" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 4l10 16m0 -16l-10 16" /></svg>';
|
$svg_icon( 'X', 'x.svg' );
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
|
|
||||||
// LinkedIn.
|
// LinkedIn.
|
||||||
$html .= '<a href="https://www.linkedin.com/sharing/share-offsite/?url=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
$html .= '<a href="https://www.linkedin.com/sharing/share-offsite/?url=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
||||||
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-linkedin" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="4" y="4" width="16" height="16" rx="2" /><line x1="8" y1="11" x2="8" y2="16" /><line x1="8" y1="8" x2="8" y2="8.01" /><line x1="12" y1="16" x2="12" y2="11" /><path d="M16 16v-3a2 2 0 0 0 -4 0" /></svg>';
|
$svg_icon( 'LinkedIn', 'linkedin.svg' );
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
|
|
||||||
// Reddit.
|
// Reddit.
|
||||||
$html .= '<a href="https://www.reddit.com/submit?url=' . $url . '&title=' . $title . '" target="_blank" rel="noopener noreferrer">';
|
$html .= '<a href="https://www.reddit.com/submit?url=' . $url . '&title=' . $title . '" target="_blank" rel="noopener noreferrer">';
|
||||||
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-reddit" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><circle cx="12" cy="12" r="9" /><path d="M12 12m-6 0a6 6 0 1 0 12 0a6 6 0 1 0 -12 0" /><path d="M12 12h0" /><path d="M8.5 13c.667 .667 1.333 1 2 1s1.333 -.333 2 -1" /></svg>';
|
$svg_icon( 'Reddit', 'reddit.svg' );
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
|
|
||||||
// WhatsApp.
|
// WhatsApp.
|
||||||
$html .= '<a href="https://wa.me/?text=' . $title . '%20' . $url . '" target="_blank" rel="noopener noreferrer">';
|
$html .= '<a href="https://www.whatsapp.com/send?text=' . $title . '%20' . $url . '" target="_blank" rel="noopener noreferrer">';
|
||||||
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-whatsapp" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21l1.65 -4.75a9 9 0 1 1 3.85 3.85z" /><path d="M9 10c1 2 3 3 5 4" /><path d="M9 13c1 1 2 2 4 3" /></svg>';
|
$svg_icon( 'WhatsApp', 'whatsapp.svg' );
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
|
|
||||||
// Pinterest.
|
// Pinterest.
|
||||||
$html .= '<a href="https://pinterest.com/pin/create/button/?url=' . $url . '&description=' . $title . '" target="_blank" rel="noopener noreferrer">';
|
$html .= '<a href="https://pinterest.com/pin/create/button/?url=' . $url . '&description=' . $title . '" target="_blank" rel="noopener noreferrer">';
|
||||||
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-pinterest" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><circle cx="12" cy="12" r="9" /><path d="M8 17c1 -2 1.5 -4 .5 -6s-.5 -4 .5 -6" /><path d="M15 11c.667 -1 1.667 -2.333 3 -4" /></svg>';
|
$svg_icon( 'Pinterest', 'pinterest.svg' );
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
|
|
||||||
// Email.
|
// Email.
|
||||||
$html .= '<a href="mailto:?subject=' . $title . '&body=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
$html .= '<a href="mailto:?subject=' . $title . '&body=' . $url . '" target="_blank" rel="noopener noreferrer">';
|
||||||
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-mail" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7l9 6l9 -6" /><rect x="3" y="5" width="18" height="14" rx="2" /></svg>';
|
$svg_icon( 'Email', 'mail.svg' );
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
|
|
||||||
$html .= '</div>';
|
$html .= '</div>';
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Helper script for the release workflow (.github/workflows/release.yml).
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
|
||||||
|
build-payload [--github] Build the JSON payload for creating a release.
|
||||||
|
Reads TAG, GH_SHA from env.
|
||||||
|
With --github, includes generate_release_notes=True.
|
||||||
|
Writes JSON to stdout.
|
||||||
|
|
||||||
|
extract-id < response.json Print the release id from a release API response.
|
||||||
|
extract-upload-url < response.json Print the upload_url from a GitHub release
|
||||||
|
response (used as the asset upload endpoint template).
|
||||||
|
|
||||||
|
This script exists to avoid bash heredoc-inside-YAML-literal indentation traps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def build_payload(github: bool) -> int:
|
||||||
|
tag = os.environ.get("TAG", "")
|
||||||
|
sha = os.environ.get("GH_SHA", "unknown")
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": tag,
|
||||||
|
"body": (
|
||||||
|
"Automated release.\n\n"
|
||||||
|
f"Built from commit {sha}.\n\n"
|
||||||
|
"See the workflow run for the artifact."
|
||||||
|
),
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
if github:
|
||||||
|
payload["generate_release_notes"] = True
|
||||||
|
print(json.dumps(payload))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def extract_field(field: str) -> int:
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"::error::Failed to parse JSON response: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
value = data.get(field, "")
|
||||||
|
if not value:
|
||||||
|
print(f"::error::Field {field!r} missing from response: {raw}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(value)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def usage() -> int:
|
||||||
|
print(__doc__, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
return usage()
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "build-payload":
|
||||||
|
return build_payload(github="--github" in sys.argv)
|
||||||
|
if cmd == "extract-id":
|
||||||
|
return extract_field("id")
|
||||||
|
if cmd == "extract-upload-url":
|
||||||
|
return extract_field("upload_url")
|
||||||
|
if cmd == "extract-tag":
|
||||||
|
return extract_field("tag_name")
|
||||||
|
return usage()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Release Workflow Design
|
||||||
|
|
||||||
|
- **Date:** 2026-08-11
|
||||||
|
- **Plugin:** Projects Portfolio (`projects-wp`)
|
||||||
|
- **Status:** Approved design, awaiting implementation plan
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
- A single tag push (`v*`) automatically produces a release-ready zip named `projects-portfolio-v<version>.zip`.
|
||||||
|
- The zip contains exactly the files WordPress needs to install and use the plugin: `projects-portfolio.php` at the root of `projects-portfolio/`, with `admin/`, `assets/`, `includes/`, `languages/`, `templates/` subdirectories plus `README.md` and `LICENSE`.
|
||||||
|
- The same zip is uploaded as a workflow artifact AND attached to a GitHub release matching the tag.
|
||||||
|
- README gets a short "Building a release" section explaining how the workflow runs and what it produces.
|
||||||
|
|
||||||
|
## 2. Non-goals
|
||||||
|
|
||||||
|
- Publishing to WordPress.org — out of scope (different credentials, different approval workflow).
|
||||||
|
- Cross-repo publishing or PAT-based auth.
|
||||||
|
- Signing, cosign, or checksums for the zip.
|
||||||
|
- Auto-bumping `PROJECTS_PORTFOLIO_VERSION` or the plugin header before a tag is cut. The workflow assumes the tag is created *after* the version has been bumped in-tree.
|
||||||
|
- Backporting tags. The workflow only acts on the tag push, not on retags of older SHAs.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
A single GitHub Actions workflow file at `.github/workflows/release.yml` runs on every `v*` tag push. It performs four sequential steps inside a single job:
|
||||||
|
|
||||||
|
1. **Checkout** — `actions/checkout@v4` at the tag's commit.
|
||||||
|
2. **Build zip** — bash script copies allowlisted runtime files into a staging directory `dist/staging/projects-portfolio/`, then runs `zip -r` from the staging root to produce `dist/projects-portfolio-v<version>.zip`.
|
||||||
|
3. **Upload artifact** — `actions/upload-artifact@v4` with name `projects-portfolio-v<tag>` and path `dist/projects-portfolio-*.zip`. Fails if no file matches.
|
||||||
|
4. **Create release** — `softprops/action-gh-release@v2` with the tag, generated release notes, and the zip file. Replaces the asset if a release with the same tag already exists (default `action-gh-release@v2` behavior).
|
||||||
|
|
||||||
|
The workflow uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission. No additional secrets are needed.
|
||||||
|
|
||||||
|
### 3.1 Why a staging directory
|
||||||
|
|
||||||
|
WordPress's "Upload Plugin" zip-install expects `<plugin-slug>/<plugin-file>.php` at the zip root. A direct `zip -r` of the working tree would put everything at the zip root — no enclosing folder — which would fail to install. The staging directory ensures the zip's internal layout has `projects-portfolio/` at the top with `projects-portfolio.php` inside it.
|
||||||
|
|
||||||
|
### 3.2 Files added
|
||||||
|
|
||||||
|
- `.github/workflows/release.yml` — the workflow.
|
||||||
|
- `README.md` — modified, with a new "Building a release" section after the existing "Connect Your Gitea Repo" section.
|
||||||
|
|
||||||
|
No PHP files are touched. The PHPUnit suite is unaffected (no test bootstrap or production code changes).
|
||||||
|
|
||||||
|
## 4. Trigger & permissions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
```
|
||||||
|
|
||||||
|
`workflow_dispatch` is intentionally omitted. To re-run for an existing tag, use the GitHub UI's "Re-run all jobs" button.
|
||||||
|
|
||||||
|
## 5. Zip filename
|
||||||
|
|
||||||
|
`projects-portfolio-v<version>.zip`, where `<version>` is the tag name with the leading `v` stripped. Examples:
|
||||||
|
|
||||||
|
- Tag `v1.1.0` → `projects-portfolio-v1.1.0.zip`
|
||||||
|
- Tag `v2.0.0-rc1` → `projects-portfolio-v2.0.0-rc1.zip`
|
||||||
|
|
||||||
|
## 6. Allowlisted zip contents
|
||||||
|
|
||||||
|
The build step copies these paths from the working tree into `dist/staging/projects-portfolio/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
projects-portfolio.php
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
admin/
|
||||||
|
assets/
|
||||||
|
includes/
|
||||||
|
languages/
|
||||||
|
templates/
|
||||||
|
```
|
||||||
|
|
||||||
|
The bundled `includes/plugin-update-checker/` library IS included — it's a runtime dependency used by `projects-portfolio.php`.
|
||||||
|
|
||||||
|
## 7. Excluded paths
|
||||||
|
|
||||||
|
The build step does **not** copy any of these. Most are already not tracked in git; the explicit list prevents accidental inclusion if any are added later:
|
||||||
|
|
||||||
|
- `composer.json`, `composer.lock` — dev-only.
|
||||||
|
- `vendor/` — Composer dev dependencies.
|
||||||
|
- `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache` — test infrastructure.
|
||||||
|
- `plans/`, `specs/` — design artifacts (not tracked, but listed for safety).
|
||||||
|
- `.github/` — the workflow file itself (would otherwise be included by the catch-all `cp -r`).
|
||||||
|
- `.gitignore`, `.vscode/`, `.claude/` — editor / project config (not tracked except `.gitignore`).
|
||||||
|
|
||||||
|
If the build script can't find a tracked source path it expects (e.g. `projects-portfolio.php` renamed), `set -e` fails the step.
|
||||||
|
|
||||||
|
## 8. Build step (verbatim)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
STAGE="dist/staging"
|
||||||
|
OUT="dist/projects-portfolio-v${VERSION}.zip"
|
||||||
|
|
||||||
|
rm -rf dist
|
||||||
|
mkdir -p "$STAGE/projects-portfolio"
|
||||||
|
|
||||||
|
cp projects-portfolio.php "$STAGE/projects-portfolio/"
|
||||||
|
cp README.md "$STAGE/projects-portfolio/"
|
||||||
|
cp LICENSE "$STAGE/projects-portfolio/"
|
||||||
|
cp -r admin/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r assets/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r includes/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r languages/ "$STAGE/projects-portfolio/"
|
||||||
|
cp -r templates/ "$STAGE/projects-portfolio/"
|
||||||
|
|
||||||
|
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
|
||||||
|
|
||||||
|
echo "Built $OUT"
|
||||||
|
unzip -l "$OUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
`unzip -l` prints the table of contents to the action log so the run is self-documenting.
|
||||||
|
|
||||||
|
## 9. README addition
|
||||||
|
|
||||||
|
Add a "Building a Release" section after the existing "Connect Your Gitea Repo" section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Building a Release
|
||||||
|
|
||||||
|
Releases are automated via GitHub Actions. To cut a new release:
|
||||||
|
|
||||||
|
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
|
||||||
|
2. Commit and push to `main`.
|
||||||
|
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
|
||||||
|
4. Push the tag: `git push origin v1.1.0`.
|
||||||
|
|
||||||
|
The `.github/workflows/release.yml` workflow runs and:
|
||||||
|
|
||||||
|
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
|
||||||
|
- Uploads the zip as a workflow artifact.
|
||||||
|
- Creates (or updates) the matching GitHub Release with the zip attached.
|
||||||
|
|
||||||
|
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Error handling
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Tag doesn't match `v*` | Workflow doesn't run. |
|
||||||
|
| `cp -r` finds a missing source path | `set -e` fails the step; no zip, no release. |
|
||||||
|
| `zip` command fails | `set -e` fails the step; no artifact upload, no release. |
|
||||||
|
| Artifact upload finds no file | `if-no-files-found: error` fails the step. |
|
||||||
|
| Release already exists for tag | `softprops/action-gh-release@v2` replaces the asset and updates notes (default behavior). |
|
||||||
|
| Missing `contents: write` permission | Job fails at checkout or release step with a clear permissions error. |
|
||||||
|
|
||||||
|
## 11. Rollout
|
||||||
|
|
||||||
|
1. Implement on a feature branch.
|
||||||
|
2. Merge into `main` (the workflow file is tracked and applies to all future tags).
|
||||||
|
3. To produce the first automated release: bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header from `1.1.0` to `1.1.1`, commit, tag `v1.1.1`, push the tag.
|
||||||
|
4. Verify on the GitHub Actions run page that: artifact uploads, zip contents look right (via the `unzip -l` log line), GitHub release is created with the zip attached.
|
||||||
|
|
||||||
|
## 12. New translatable strings
|
||||||
|
|
||||||
|
None. The workflow file and README change introduce no user-facing strings.
|
||||||
@@ -4,7 +4,7 @@ $is_fse_theme = wp_theme_has_theme_json() && function_exists( 'wp_is_block_theme
|
|||||||
|
|
||||||
if ( $is_fse_theme ) {
|
if ( $is_fse_theme ) {
|
||||||
wp_head();
|
wp_head();
|
||||||
echo do_blocks( '<!-- wp:template-part {"slug":"header"} /-->' );
|
echo do_blocks( '<!-- wp:template-part {"slug":"header"} /-->' ); // phpcs:ignore
|
||||||
} else {
|
} else {
|
||||||
get_header();
|
get_header();
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,13 @@ $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 );
|
||||||
|
|
||||||
|
$is_website = projects_portfolio_is_website( $project_id );
|
||||||
|
|
||||||
|
if ( $is_website ) {
|
||||||
|
$site_url = projects_portfolio_get_site_url( $project_id );
|
||||||
|
$site_platform = projects_portfolio_get_site_platform( $project_id );
|
||||||
|
$site_shot_url = projects_portfolio_get_site_screenshot_url( $project_id, 'large' );
|
||||||
|
} else {
|
||||||
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
|
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
|
||||||
if ( '' === $repo_url ) {
|
if ( '' === $repo_url ) {
|
||||||
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
|
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
|
||||||
@@ -29,6 +36,7 @@ $last_updated = $repo_data['updated_at'] ?? '';
|
|||||||
$language = $repo_data['language'] ?? '';
|
$language = $repo_data['language'] ?? '';
|
||||||
$license = $repo_data['license']['name'] ?? 'None';
|
$license = $repo_data['license']['name'] ?? 'None';
|
||||||
$browse_url = $repo_url ? projects_portfolio_get_repo_browse_url( $project_id ) : '';
|
$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 ) );
|
||||||
@@ -49,6 +57,13 @@ if ( $last_updated ) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="project-main-content">
|
<div class="project-main-content">
|
||||||
|
<?php if ( ! empty( $site_shot_url ) ) : ?>
|
||||||
|
<div class="project-screenshot">
|
||||||
|
<a href="<?php echo esc_url( $site_url ); ?>" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="<?php echo esc_url( $site_shot_url ); ?>" alt="<?php echo esc_attr( get_the_title() ); ?>" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<div class="project-description">
|
<div class="project-description">
|
||||||
<h1 class="project-title">
|
<h1 class="project-title">
|
||||||
<?php
|
<?php
|
||||||
@@ -68,13 +83,22 @@ 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 ( $repo_url ) : ?>
|
<?php if ( $is_website ) : ?>
|
||||||
<a href="<?php echo esc_url( $browse_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer">
|
<?php if ( ! empty( $site_url ) ) : ?>
|
||||||
|
<a href="<?php echo esc_url( $site_url ); ?>" class="button project-repo-button" target="_blank" rel="noopener noreferrer">
|
||||||
|
<?php esc_html_e( 'Visit Site', 'projects-wp' ); ?>
|
||||||
|
</a>
|
||||||
|
<?php else : ?>
|
||||||
|
<p><?php esc_html_e( 'Website:', 'projects-wp' ); ?> <?php esc_html_e( 'No URL linked.', 'projects-wp' ); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ( ! empty( $repo_url ) ) : ?>
|
||||||
|
<a href="<?php echo esc_url( $browse_url ); ?>" class="button project-repo-button" target="_blank" rel="noopener noreferrer">
|
||||||
<?php esc_html_e( 'View Repo', 'projects-wp' ); ?>
|
<?php esc_html_e( 'View Repo', 'projects-wp' ); ?>
|
||||||
</a>
|
</a>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<p><?php esc_html_e( '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; ?>
|
||||||
|
<?php do_action( 'projects_after_download_button', $project_id ); ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="project-meta">
|
<div class="project-meta">
|
||||||
@@ -83,6 +107,14 @@ if ( $last_updated ) {
|
|||||||
?>
|
?>
|
||||||
<table class="project-meta-table">
|
<table class="project-meta-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php if ( $is_website ) : ?>
|
||||||
|
<?php if ( $site_platform ) : ?>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Platform:', 'projects-wp' ); ?></th>
|
||||||
|
<td><?php echo nl2br( esc_html( $site_platform ) ); // phpcs:ignore ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else : ?>
|
||||||
<?php if ( $settings['templates']['version'] ) : ?>
|
<?php if ( $settings['templates']['version'] ) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e( 'Version:', 'projects-wp' ); ?></th>
|
<th><?php esc_html_e( 'Version:', 'projects-wp' ); ?></th>
|
||||||
@@ -115,43 +147,45 @@ if ( $last_updated ) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
if ( ! empty( $repo_data ) ) :
|
if ( ! empty( $repo_data ) ) :
|
||||||
if ( isset( $repo_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( $repo_data['stargazers_count'] ); ?></td>
|
<td><?php esc_html_e( $repo_data['stargazers_count'] ); ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif;
|
<?php
|
||||||
if ( isset( $repo_data['forks_count'] ) && $settings['templates']['forks'] ) : ?>
|
endif;
|
||||||
|
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( $repo_data['forks_count'] ); ?></td>
|
<td><?php esc_html_e( $repo_data['forks_count'] ); ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif;
|
<?php
|
||||||
if ( isset( $repo_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) : ?>
|
endif;
|
||||||
|
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( $repo_data['open_issues_count'] ); ?></td>
|
<td><?php esc_html_e( $repo_data['open_issues_count'] ); ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif;
|
<?php
|
||||||
endif;
|
endif;
|
||||||
|
endif;
|
||||||
|
endif; // end repo-mode meta rows (else branch of $is_website).
|
||||||
?>
|
?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
|
<?php if ( ! $is_website && $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
|
||||||
<a href="<?php echo esc_url( $browse_url ); ?>">
|
|
||||||
<div class="project-owner">
|
<div class="project-owner">
|
||||||
<span>
|
<span class="project-owner-avatar" style="display:block !important;width:50px !important;height:50px !important;min-width:50px !important;min-height:50px !important;max-width:50px !important;max-height:50px !important;border-radius:50% !important;background-image:url('<?php echo esc_url( $owner_avatar ); ?>') !important;background-size:40px 40px !important;background-position:center center !important;background-repeat:no-repeat !important;flex-shrink:0 !important;flex-grow:0 !important;" aria-label="<?php echo esc_attr( $owner_name ); ?>" role="img"></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%;" />
|
<span class="project-owner-name">
|
||||||
|
<a href="<?php echo esc_url( $owner_url ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $owner_name ); ?></a>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span class="project-owner-follow">
|
||||||
<strong>
|
<a class="button project-follow-button" href="<?php echo esc_url( $owner_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Follow', 'projects-wp' ); ?></a>
|
||||||
<a href="<?php echo esc_url( $owner_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( $owner_name ); ?></a>
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<a class="github-button" href="https://github.com/<?php esc_html_e( $owner_name ); ?>" data-show-count="true" data-color-scheme="no-preference: light; light: light; dark: dark;" data-size="large" aria-label="Follow @<?php esc_html_e( $owner_name ); ?> on GitHub"><?php esc_attr_e( 'Follow', 'projects-wp' ) ?></a>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -165,7 +199,7 @@ if ( $last_updated ) {
|
|||||||
<?php
|
<?php
|
||||||
if ( $is_fse_theme ) {
|
if ( $is_fse_theme ) {
|
||||||
wp_footer();
|
wp_footer();
|
||||||
echo do_blocks( '<!-- wp:template-part {"slug":"footer"} /-->' );
|
echo do_blocks( '<!-- wp:template-part {"slug":"footer"} /-->' ); // phpcs:ignore
|
||||||
} else {
|
} else {
|
||||||
get_footer();
|
get_footer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,29 @@ class Gitea_Provider_Test extends \PHPUnit\Framework\TestCase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_release_object_shape_picks_first_tag_from_object(): void {
|
||||||
|
// Gitea's /releases/latest returns a single release OBJECT (associative array),
|
||||||
|
// not a list. The implementation must accept either shape.
|
||||||
|
$body = json_encode( [
|
||||||
|
'tag_name' => 'v2.1.0',
|
||||||
|
'name' => 'v2.1.0',
|
||||||
|
'assets' => [
|
||||||
|
[ 'name' => 'release.zip', 'browser_download_url' => 'https://example/release.zip' ],
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->twice() // get_release_url and get_latest_version both fetch
|
||||||
|
->andReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => $body,
|
||||||
|
] );
|
||||||
|
|
||||||
|
$provider = $this->make();
|
||||||
|
$this->assertSame( 'https://example/release.zip', $provider->get_release_url() );
|
||||||
|
$this->assertSame( 'v2.1.0', $provider->get_latest_version() );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_release_url_empty_array_returns_null(): void {
|
public function test_release_url_empty_array_returns_null(): void {
|
||||||
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
->once()
|
->once()
|
||||||
|
|||||||
@@ -142,4 +142,115 @@ class Metabox_Test extends \PHPUnit\Framework\TestCase {
|
|||||||
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
|
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
|
||||||
$this->assertContains( '_projects_portfolio_github_url', $deleted );
|
$this->assertContains( '_projects_portfolio_github_url', $deleted );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_website_mode_persists_website_fields_and_clears_repo_fields(): 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_is_website'] = '1';
|
||||||
|
$_POST['projects_portfolio_site_url'] = 'https://example.com';
|
||||||
|
$_POST['projects_portfolio_site_platform'] = 'WordPress';
|
||||||
|
$_POST['projects_portfolio_site_screenshot_id'] = '42';
|
||||||
|
|
||||||
|
projects_portfolio_save_meta_box( 11 );
|
||||||
|
|
||||||
|
$this->assertSame( '1', $existing['_projects_portfolio_is_website'] );
|
||||||
|
$this->assertSame( 'https://example.com', $existing['_projects_portfolio_site_url'] );
|
||||||
|
$this->assertSame( 'WordPress', $existing['_projects_portfolio_site_platform'] );
|
||||||
|
$this->assertSame( 42, $existing['_projects_portfolio_site_screenshot_id'] );
|
||||||
|
|
||||||
|
// Repo-mode fields should be cleared when switching to website mode.
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_provider', $existing );
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_repo_url', $existing );
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
|
||||||
|
$this->assertContains( '_projects_portfolio_provider', $deleted );
|
||||||
|
$this->assertContains( '_projects_portfolio_repo_url', $deleted );
|
||||||
|
$this->assertContains( '_projects_portfolio_github_url', $deleted );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unchecked_website_checkbox_clears_website_fields(): void {
|
||||||
|
$existing = [
|
||||||
|
'_projects_portfolio_is_website' => '1',
|
||||||
|
'_projects_portfolio_site_url' => 'https://example.com',
|
||||||
|
'_projects_portfolio_site_platform' => 'WordPress',
|
||||||
|
'_projects_portfolio_site_screenshot_id' => 42,
|
||||||
|
];
|
||||||
|
$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';
|
||||||
|
// is_website is absent — repo mode is implied.
|
||||||
|
|
||||||
|
projects_portfolio_save_meta_box( 13 );
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_is_website', $existing );
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_site_url', $existing );
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_site_platform', $existing );
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_site_screenshot_id', $existing );
|
||||||
|
$this->assertContains( '_projects_portfolio_is_website', $deleted );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_existing_repo_post_without_checkbox_keeps_repo_fields(): void {
|
||||||
|
$existing = [
|
||||||
|
'_projects_portfolio_provider' => 'github',
|
||||||
|
'_projects_portfolio_repo_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;
|
||||||
|
},
|
||||||
|
'delete_post_meta' => function () { return true; },
|
||||||
|
'wp_verify_nonce' => function () { return true; },
|
||||||
|
'wp_nonce_field' => function () { /* noop */ },
|
||||||
|
] );
|
||||||
|
|
||||||
|
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
|
||||||
|
// is_website not present in $_POST — should be treated as unchecked.
|
||||||
|
|
||||||
|
projects_portfolio_save_meta_box( 17 );
|
||||||
|
|
||||||
|
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
|
||||||
|
$this->assertSame( 'https://github.com/owner/repo', $existing['_projects_portfolio_repo_url'] );
|
||||||
|
$this->assertArrayNotHasKey( '_projects_portfolio_is_website', $existing );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ if ( ! function_exists( 'wp_unslash' ) ) {
|
|||||||
if ( ! function_exists( 'esc_url_raw' ) ) {
|
if ( ! function_exists( 'esc_url_raw' ) ) {
|
||||||
function esc_url_raw( $url ) { return $url; }
|
function esc_url_raw( $url ) { return $url; }
|
||||||
}
|
}
|
||||||
|
if ( ! function_exists( 'sanitize_text_field' ) ) {
|
||||||
|
function sanitize_text_field( $str ) { return is_string( $str ) ? trim( $str ) : ''; }
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'absint' ) ) {
|
||||||
|
function absint( $maybeint ) { return abs( (int) $maybeint ); }
|
||||||
|
}
|
||||||
|
|
||||||
// Minimal WP_Error stub if Brain\Monkey doesn't supply one.
|
// Minimal WP_Error stub if Brain\Monkey doesn't supply one.
|
||||||
if ( ! class_exists( 'WP_Error' ) ) {
|
if ( ! class_exists( 'WP_Error' ) ) {
|
||||||
|
|||||||