chore: initial commit — Buddy v0.1.0 (Phase A complete)
Buddy is born. First commit of a new standalone WordPress plugin — the spiritual successor to the tamagotchi that once lived inside A-WP-Notes v1.1.5 (gracefully retired). Rebuilt from scratch with all the v3-discipline lessons baked in from day one. PHASE A — pet exists - Dashboard widget at WP Admin → Dashboard showing SVG character + name + mood label + four stats bars. - Dedicated admin page at WP Admin → Buddy → My Buddy (bigger view). - About page with side-by-side intro + plain-prose cards (Logbook About-page pattern carried forward). - Settings page with name-rename form + Updates panel. - Per-user state in user_meta key buddy_state (each WP admin gets their own pet, no shared state). - Inline SVG sprite renderer with three mood tones (happy/neutral/ sad) and three sizes (sm/md/lg). CSS keyframe animations: bobbing + periodic blinking. Zero image files. - Self-hosted update checker wired up from commit 1, ported from Logbook v3.3.5: /releases/latest with /tags?limit=1 fallback, 12h success cache / 1h negative cache. UI on Settings page. - dashicons-pets admin-menu icon — literal paw-print, brand match. ARCHITECTURE LOCKED FROM COMMIT 1 - Single-word brand name "Buddy" — no WP prefix, no future rebrand. - Public GPL v2+ Gitea repo (ranger/a-buddy). - Constants prefix BUDDY_*, function prefix buddy_*, text domain buddy. Clean naming throughout — none of Logbook's wp-notes-* historical-artifact baggage. - Single H1 per admin page, no nested toggle boxes, no duplicate sections — Tier-1 discipline carried forward from Logbook. - All assets local (inline SVG, plain CSS), no third-party CDN, no Gravatar-style external pings. NOT IN THIS RELEASE (planned) - Phase B — Feed/Play/Clean/Sleep interactions + cooldown timers. - Phase C — WP-cron decay + "Buddy is hungry" dismissible notices (port the persistent-dismissal pattern from Logbook). - Phase D — Multiple species (dog, dragon, sprite), per-species personality phrases. - Phase E — Site-health hook: pet stats react to wp_get_site_health() results. The killer feature. - Phase F — Pro tier (€2.99 lifetime) with custom skins + multi-pet. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* Buddy About page — explains what the plugin is, who it's for, and
|
||||
* keeps a compact version history with a link out to the canonical
|
||||
* CHANGELOG.md on Gitea.
|
||||
*
|
||||
* Layout: a clean side-by-side intro row at the top (sprite + intro
|
||||
* + CTA), then plain prose cards. Discipline carried from Logbook —
|
||||
* no nested toggle boxes, no duplicate sections, single H1.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
function buddy_render_about_page() {
|
||||
if ( ! current_user_can( 'read' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'buddy' ) );
|
||||
}
|
||||
?>
|
||||
<style>
|
||||
.buddy-about-intro {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0 28px;
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #ccd0d4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.buddy-about-intro__sprite {
|
||||
flex: 0 0 160px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.buddy-about-intro__body {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
}
|
||||
.buddy-about-intro__body h2 { margin-top: 0; }
|
||||
|
||||
.buddy-about-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ccd0d4;
|
||||
border-radius: 4px;
|
||||
padding: 20px 24px;
|
||||
margin: 0 0 22px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.buddy-about-card h2 { margin: 0 0 10px; font-size: 16px; }
|
||||
.buddy-about-card p:last-child { margin-bottom: 0; }
|
||||
.buddy-about-card ul { margin: 8px 0 0 18px; list-style: disc; }
|
||||
.buddy-about-card ul li { margin-bottom: 4px; }
|
||||
.buddy-about-card--versions ul { list-style: none; margin-left: 0; }
|
||||
.buddy-about-card--versions li { margin-bottom: 12px; }
|
||||
.buddy-about-card--versions .ver { font-weight: 600; color: #2271b1; }
|
||||
.buddy-about-card--versions .latest { display:inline-block; margin-left:6px; padding:1px 7px; background:#00a32a; color:#fff; border-radius:9px; font-size:11px; font-weight:600; }
|
||||
.buddy-about-changelog-link { display: inline-block; margin-top: 6px; font-size: 13px; color: #646970; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<h1 class="wp-heading-inline"><?php esc_html_e( 'About', 'buddy' ); ?></h1>
|
||||
<span class="page-title-action">v<?php echo esc_html( BUDDY_VERSION ); ?></span>
|
||||
<hr class="wp-header-end">
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="buddy-about-intro">
|
||||
<div class="buddy-about-intro__sprite">
|
||||
<?php buddy_render_sprite( 'default', 'happy', 'lg' ); ?>
|
||||
</div>
|
||||
<div class="buddy-about-intro__body">
|
||||
<h2>Buddy <span style="color:#646970; font-weight:400;">v<?php echo esc_html( BUDDY_VERSION ); ?></span></h2>
|
||||
<p>A friendly little companion that lives in your WordPress dashboard. The idea: keep Buddy's stats up, eventually Buddy's mood will reflect how well you're taking care of your site itself. Gamifies WordPress maintenance with a bit of charm.</p>
|
||||
<p style="margin-bottom:0;">
|
||||
<a href="<?php echo esc_url( admin_url( 'admin.php?page=buddy' ) ); ?>" class="button button-primary"><?php esc_html_e( 'Go to My Buddy →', 'buddy' ); ?></a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buddy-about-card">
|
||||
<h2><?php esc_html_e( 'What Buddy does', 'buddy' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Buddy is a small virtual pet stored per-user in your WordPress site. Each WP admin gets their own Buddy with its own name, species, and stats. Everything lives in your site\'s own database — nothing leaves the server.', 'buddy' ); ?></p>
|
||||
<p><?php esc_html_e( 'Right now (v0.1.0): Buddy exists. You can see them on the WordPress Dashboard widget and on the dedicated Buddy admin page. Coming next: feed / play / clean / sleep interactions, time-based stat decay, multiple species, and the killer feature — stats that react to your actual WordPress site health.', 'buddy' ); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="buddy-about-card">
|
||||
<h2><?php esc_html_e( 'Who Buddy is for', 'buddy' ); ?></h2>
|
||||
<ul>
|
||||
<li><strong><?php esc_html_e( 'WordPress admins', 'buddy' ); ?></strong> <?php esc_html_e( 'who want a small daily reason to keep their site tidy.', 'buddy' ); ?></li>
|
||||
<li><strong><?php esc_html_e( 'Freelancers', 'buddy' ); ?></strong> <?php esc_html_e( 'managing multiple client sites and wanting a visual gauge of each one\'s health.', 'buddy' ); ?></li>
|
||||
<li><strong><?php esc_html_e( '90s kids', 'buddy' ); ?></strong> <?php esc_html_e( 'who remember when computers had pets in them.', 'buddy' ); ?></li>
|
||||
<li><strong><?php esc_html_e( 'Anyone', 'buddy' ); ?></strong> <?php esc_html_e( 'who wants their WordPress admin to feel slightly less corporate.', 'buddy' ); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="buddy-about-card buddy-about-card--versions">
|
||||
<h2><?php esc_html_e( 'Version history', 'buddy' ); ?></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="ver">v0.1.0</span> — 25 May 2026 <span class="latest">latest</span><br>
|
||||
<?php esc_html_e( 'First release. Phase A complete: Buddy exists. Dashboard widget + dedicated admin page show the SVG character, name, mood label, four stats bars. Self-hosted update checker wired up to Gitea from commit 1. No interactions yet — that\'s next.', 'buddy' ); ?>
|
||||
</li>
|
||||
</ul>
|
||||
<a class="buddy-about-changelog-link"
|
||||
href="https://git.davidtkeane.com/ranger/a-buddy/src/branch/main/CHANGELOG.md"
|
||||
target="_blank" rel="noopener">
|
||||
<?php esc_html_e( 'View the full CHANGELOG.md →', 'buddy' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* Buddy main admin page — renders the bigger view at WP Admin → Buddy
|
||||
* → My Buddy. Same data as the dashboard widget but with more room
|
||||
* for Phase B (interactions), Phase D (species picker), etc. to slot
|
||||
* in cleanly later.
|
||||
*
|
||||
* Discipline anchored from Logbook v3 lessons: one H1 per page, no
|
||||
* nested toggles, no duplicate sections. Each card owns one concern.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
/**
|
||||
* Top-level "Buddy → My Buddy" page renderer. Hooked via
|
||||
* add_menu_page() in buddy.php.
|
||||
*/
|
||||
function buddy_render_main_page() {
|
||||
if ( ! current_user_can( 'read' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view Buddy.', 'buddy' ) );
|
||||
}
|
||||
|
||||
$state = buddy_get_state();
|
||||
$mood = buddy_overall_mood( $state );
|
||||
$tone = buddy_mood_label( $mood );
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1 class="wp-heading-inline">🐾 <?php esc_html_e( 'Buddy', 'buddy' ); ?></h1>
|
||||
<span class="page-title-action">v<?php echo esc_html( BUDDY_VERSION ); ?></span>
|
||||
<hr class="wp-header-end">
|
||||
|
||||
<p class="description" style="max-width: 720px;">
|
||||
<?php esc_html_e( 'Your dashboard pet. Keep its stats up — eventually they will reflect how well you take care of your WordPress site.', 'buddy' ); ?>
|
||||
<a href="<?php echo esc_url( admin_url( 'admin.php?page=buddy-about' ) ); ?>"><?php esc_html_e( 'Read more on the About page →', 'buddy' ); ?></a>
|
||||
</p>
|
||||
|
||||
<div class="buddy-main">
|
||||
<div class="buddy-main__card buddy-main__card--pet">
|
||||
<?php buddy_render_sprite( $state['species'], $tone['tone'], 'lg' ); ?>
|
||||
<h2 class="buddy-main__name">
|
||||
<?php echo esc_html( $state['name'] ); ?>
|
||||
<span class="buddy-main__mood buddy-main__mood--<?php echo esc_attr( $tone['tone'] ); ?>">
|
||||
<?php echo esc_html( $tone['label'] ); ?>
|
||||
</span>
|
||||
</h2>
|
||||
<p class="buddy-main__age">
|
||||
<?php
|
||||
$age_seconds = max( 0, time() - (int) $state['born_at'] );
|
||||
$age_days = (int) floor( $age_seconds / DAY_IN_SECONDS );
|
||||
echo esc_html(
|
||||
$age_days < 1
|
||||
? __( 'Adopted today', 'buddy' )
|
||||
: sprintf( _n( '%d day old', '%d days old', $age_days, 'buddy' ), $age_days )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="buddy-main__card buddy-main__card--stats">
|
||||
<h2><?php esc_html_e( 'How is Buddy doing?', 'buddy' ); ?></h2>
|
||||
<?php buddy_render_stats_bars( $state ); ?>
|
||||
<p class="buddy-main__note">
|
||||
<?php esc_html_e( 'Coming soon: feed, play, clean, and sleep actions to keep Buddy thriving. Also: stats will react to your WordPress site\'s health.', 'buddy' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* Buddy dashboard widget — the pet appears on the main WordPress
|
||||
* Dashboard (`/wp-admin/index.php`) so the user sees it without
|
||||
* navigating anywhere. This is the "Buddy exists" baseline of Phase A.
|
||||
*
|
||||
* The widget shows: the SVG character + the pet's name + current
|
||||
* mood label + four stats bars. No interaction yet (Phase B will add
|
||||
* Feed / Play / Clean / Sleep). The render also includes a "Visit
|
||||
* Buddy" link that jumps to the dedicated admin page.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
add_action( 'wp_dashboard_setup', 'buddy_register_dashboard_widget' );
|
||||
function buddy_register_dashboard_widget() {
|
||||
if ( ! current_user_can( 'read' ) ) { return; }
|
||||
|
||||
wp_add_dashboard_widget(
|
||||
'buddy_dashboard_widget',
|
||||
'🐾 ' . __( 'Buddy', 'buddy' ),
|
||||
'buddy_render_dashboard_widget'
|
||||
);
|
||||
}
|
||||
|
||||
function buddy_render_dashboard_widget() {
|
||||
$state = buddy_get_state();
|
||||
$mood = buddy_overall_mood( $state );
|
||||
$tone = buddy_mood_label( $mood );
|
||||
?>
|
||||
<div class="buddy-widget">
|
||||
<div class="buddy-widget__pet">
|
||||
<?php buddy_render_sprite( $state['species'], $tone['tone'], 'md' ); ?>
|
||||
</div>
|
||||
<div class="buddy-widget__info">
|
||||
<div class="buddy-widget__name">
|
||||
<?php echo esc_html( $state['name'] ); ?>
|
||||
<span class="buddy-widget__mood buddy-widget__mood--<?php echo esc_attr( $tone['tone'] ); ?>">
|
||||
<?php echo esc_html( $tone['label'] ); ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php buddy_render_stats_bars( $state ); ?>
|
||||
<p class="buddy-widget__cta">
|
||||
<a href="<?php echo esc_url( admin_url( 'admin.php?page=buddy' ) ); ?>" class="button button-small">
|
||||
<?php esc_html_e( 'Visit Buddy →', 'buddy' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the four stats as labelled progress bars. Reused by the
|
||||
* dedicated admin page so they look identical wherever Buddy appears.
|
||||
*/
|
||||
function buddy_render_stats_bars( array $state ) {
|
||||
$stats = array(
|
||||
'hunger' => array( 'label' => __( 'Hunger', 'buddy' ), 'icon' => '🍎' ),
|
||||
'happiness' => array( 'label' => __( 'Happiness', 'buddy' ), 'icon' => '😊' ),
|
||||
'health' => array( 'label' => __( 'Health', 'buddy' ), 'icon' => '💚' ),
|
||||
'energy' => array( 'label' => __( 'Energy', 'buddy' ), 'icon' => '⚡' ),
|
||||
);
|
||||
echo '<ul class="buddy-stats">';
|
||||
foreach ( $stats as $key => $meta ) {
|
||||
$val = max( 0, min( 100, (int) ( $state[ $key ] ?? 0 ) ) );
|
||||
$low = $val < 30;
|
||||
printf(
|
||||
'<li class="buddy-stat"><span class="buddy-stat__icon" aria-hidden="true">%s</span>'
|
||||
. '<span class="buddy-stat__label">%s</span>'
|
||||
. '<span class="buddy-stat__bar"><span class="buddy-stat__fill%s" style="width:%d%%"></span></span>'
|
||||
. '<span class="buddy-stat__num">%d</span></li>',
|
||||
esc_html( $meta['icon'] ),
|
||||
esc_html( $meta['label'] ),
|
||||
$low ? ' buddy-stat__fill--low' : '',
|
||||
$val,
|
||||
$val
|
||||
);
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* Buddy settings page — placeholder shell for Phase A. Settings will
|
||||
* grow in later phases:
|
||||
* Phase B: cooldown timers
|
||||
* Phase C: decay tick interval
|
||||
* Phase D: species picker (currently only one available)
|
||||
* Phase E: site-health integration toggle
|
||||
*
|
||||
* For v0.1.0 this page exists to (a) host the Updates panel from the
|
||||
* Logbook-style update checker, and (b) let the user rename their
|
||||
* Buddy.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
/**
|
||||
* Handle the rename form POST. Capability-gated to anyone who can
|
||||
* read (every WP user) since each user owns their own Buddy.
|
||||
*/
|
||||
add_action( 'admin_init', 'buddy_handle_rename_post' );
|
||||
function buddy_handle_rename_post() {
|
||||
if ( empty( $_POST['buddy_rename_submit'] ) ) { return; }
|
||||
if ( ! current_user_can( 'read' ) ) { return; }
|
||||
if ( ! isset( $_POST['buddy_rename_nonce'] )
|
||||
|| ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['buddy_rename_nonce'] ) ), 'buddy_rename' ) ) {
|
||||
return;
|
||||
}
|
||||
$new_name = isset( $_POST['buddy_name'] ) ? sanitize_text_field( wp_unslash( $_POST['buddy_name'] ) ) : '';
|
||||
if ( $new_name === '' || mb_strlen( $new_name ) > 32 ) { return; }
|
||||
buddy_update_state( array( 'name' => $new_name ) );
|
||||
|
||||
add_action( 'admin_notices', function () {
|
||||
printf(
|
||||
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
|
||||
esc_html__( 'Buddy renamed.', 'buddy' )
|
||||
);
|
||||
} );
|
||||
}
|
||||
|
||||
function buddy_render_settings_page() {
|
||||
if ( ! current_user_can( 'read' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'buddy' ) );
|
||||
}
|
||||
$state = buddy_get_state();
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'Buddy Settings', 'buddy' ); ?></h1>
|
||||
|
||||
<form method="post" style="max-width: 720px; background:#fff; padding:18px 22px; border:1px solid #ccd0d4; border-radius:4px; margin-top: 16px;">
|
||||
<?php wp_nonce_field( 'buddy_rename', 'buddy_rename_nonce' ); ?>
|
||||
<h2 style="margin-top:0;"><?php esc_html_e( 'Name', 'buddy' ); ?></h2>
|
||||
<p>
|
||||
<label for="buddy_name"><?php esc_html_e( 'What is your Buddy called?', 'buddy' ); ?></label><br>
|
||||
<input type="text"
|
||||
name="buddy_name"
|
||||
id="buddy_name"
|
||||
value="<?php echo esc_attr( $state['name'] ); ?>"
|
||||
maxlength="32"
|
||||
class="regular-text">
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit" name="buddy_rename_submit" class="button button-primary">
|
||||
<?php esc_html_e( 'Save name', 'buddy' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<?php
|
||||
// Updates panel — defined in inc/updater.php.
|
||||
if ( function_exists( 'buddy_render_updates_panel' ) ) {
|
||||
buddy_render_updates_panel();
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* Buddy sprite rendering — pure inline SVG, no image files, no GIFs,
|
||||
* no sprite sheets. CSS animations live alongside in buddy.css.
|
||||
*
|
||||
* Phase A: one species ("default") — a small round yellow chibi
|
||||
* character with eyes and a smile. Phase D will branch this into
|
||||
* dog / dragon / sprite / etc.
|
||||
*
|
||||
* Mood tone changes the expression: 'happy' = open smile, 'neutral'
|
||||
* = flat mouth, 'sad' = downturned. Eyes blink via CSS keyframes
|
||||
* regardless of tone (it's always alive).
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
/**
|
||||
* Echo the inline SVG for a buddy character.
|
||||
*
|
||||
* @param string $species Currently only 'default'.
|
||||
* @param string $tone 'happy' | 'neutral' | 'sad'
|
||||
* @param string $size 'sm' (64px), 'md' (96px), 'lg' (160px). Just sets a class.
|
||||
*/
|
||||
function buddy_render_sprite( $species = 'default', $tone = 'happy', $size = 'md' ) {
|
||||
$species = sanitize_key( $species );
|
||||
$tone = in_array( $tone, array( 'happy', 'neutral', 'sad' ), true ) ? $tone : 'happy';
|
||||
$size = in_array( $size, array( 'sm', 'md', 'lg' ), true ) ? $size : 'md';
|
||||
|
||||
// Mouth path varies by tone.
|
||||
$mouth = array(
|
||||
'happy' => 'M 42 60 Q 50 70 58 60', // smile
|
||||
'neutral' => 'M 42 64 L 58 64', // flat
|
||||
'sad' => 'M 42 66 Q 50 58 58 66', // frown
|
||||
);
|
||||
$mouth_d = $mouth[ $tone ];
|
||||
|
||||
// Body fill: subtle shift by tone.
|
||||
$body_fill = ( $tone === 'sad' ) ? '#d8b04a' : '#f4c64e';
|
||||
?>
|
||||
<svg class="buddy-sprite buddy-sprite--<?php echo esc_attr( $size ); ?> buddy-sprite--<?php echo esc_attr( $tone ); ?>"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="<?php echo esc_attr( sprintf( __( 'Buddy is %s', 'buddy' ), $tone ) ); ?>">
|
||||
<!-- body -->
|
||||
<circle cx="50" cy="55" r="32" fill="<?php echo esc_attr( $body_fill ); ?>" stroke="#c9941d" stroke-width="2" />
|
||||
<!-- left eye -->
|
||||
<g class="buddy-sprite__eye buddy-sprite__eye--left">
|
||||
<circle cx="40" cy="46" r="5" fill="#2c3338" />
|
||||
<circle cx="41.2" cy="45" r="1.5" fill="#fff" />
|
||||
</g>
|
||||
<!-- right eye -->
|
||||
<g class="buddy-sprite__eye buddy-sprite__eye--right">
|
||||
<circle cx="60" cy="46" r="5" fill="#2c3338" />
|
||||
<circle cx="61.2" cy="45" r="1.5" fill="#fff" />
|
||||
</g>
|
||||
<!-- mouth -->
|
||||
<path d="<?php echo esc_attr( $mouth_d ); ?>"
|
||||
stroke="#2c3338" stroke-width="2" fill="none" stroke-linecap="round" />
|
||||
<!-- cheeks (only when happy or neutral) -->
|
||||
<?php if ( $tone !== 'sad' ) : ?>
|
||||
<circle cx="33" cy="58" r="3" fill="#f4866a" opacity="0.55" />
|
||||
<circle cx="67" cy="58" r="3" fill="#f4866a" opacity="0.55" />
|
||||
<?php endif; ?>
|
||||
<!-- tiny feet -->
|
||||
<ellipse cx="42" cy="88" rx="6" ry="3" fill="#c9941d" />
|
||||
<ellipse cx="58" cy="88" rx="6" ry="3" fill="#c9941d" />
|
||||
</svg>
|
||||
<?php
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* Buddy state — persistence for the pet's stats and identity.
|
||||
*
|
||||
* Storage: per-user, in `wp_usermeta` under key `buddy_state`. A single
|
||||
* JSON-shaped associative array. Picked user_meta over site-wide
|
||||
* options so multiple admins on the same site each get their own pet,
|
||||
* matching the per-user mental model.
|
||||
*
|
||||
* Stats range 0–100. Higher = better. Decay happens via WP-cron later
|
||||
* (Phase C); for Phase A the stats just persist as last set.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
const BUDDY_META_KEY = 'buddy_state';
|
||||
|
||||
/**
|
||||
* Default state for a freshly-adopted pet. Used the first time a user
|
||||
* visits a Buddy-rendering page.
|
||||
*/
|
||||
function buddy_default_state() {
|
||||
return array(
|
||||
'name' => __( 'Buddy', 'buddy' ),
|
||||
'species' => 'default',
|
||||
'hunger' => 80,
|
||||
'happiness' => 80,
|
||||
'health' => 90,
|
||||
'energy' => 70,
|
||||
'born_at' => time(),
|
||||
'last_tick' => time(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user's Buddy state, falling back to defaults and
|
||||
* persisting them on first read so the pet has a "birthday" timestamp.
|
||||
*
|
||||
* @param int $user_id Optional. Defaults to current user.
|
||||
* @return array { name, species, hunger, happiness, health, energy, born_at, last_tick }
|
||||
*/
|
||||
function buddy_get_state( $user_id = 0 ) {
|
||||
$user_id = $user_id ? (int) $user_id : get_current_user_id();
|
||||
if ( ! $user_id ) { return buddy_default_state(); }
|
||||
|
||||
$state = get_user_meta( $user_id, BUDDY_META_KEY, true );
|
||||
if ( ! is_array( $state ) || empty( $state ) ) {
|
||||
$state = buddy_default_state();
|
||||
update_user_meta( $user_id, BUDDY_META_KEY, $state );
|
||||
}
|
||||
|
||||
// Merge against defaults so newly-added keys appear with sensible
|
||||
// values for users adopted under earlier versions.
|
||||
return array_merge( buddy_default_state(), $state );
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a partial state update for the current user. Unknown keys
|
||||
* are silently dropped; values are clamped to valid ranges.
|
||||
*
|
||||
* @param array $patch Keys: name (str), species (str), hunger/happiness/health/energy (int 0–100).
|
||||
* @return array The full updated state.
|
||||
*/
|
||||
function buddy_update_state( array $patch, $user_id = 0 ) {
|
||||
$user_id = $user_id ? (int) $user_id : get_current_user_id();
|
||||
if ( ! $user_id ) { return buddy_default_state(); }
|
||||
|
||||
$state = buddy_get_state( $user_id );
|
||||
|
||||
$stat_keys = array( 'hunger', 'happiness', 'health', 'energy' );
|
||||
foreach ( $stat_keys as $k ) {
|
||||
if ( array_key_exists( $k, $patch ) ) {
|
||||
$state[ $k ] = max( 0, min( 100, (int) $patch[ $k ] ) );
|
||||
}
|
||||
}
|
||||
if ( array_key_exists( 'name', $patch ) ) {
|
||||
$name = sanitize_text_field( (string) $patch['name'] );
|
||||
if ( $name !== '' && mb_strlen( $name ) <= 32 ) {
|
||||
$state['name'] = $name;
|
||||
}
|
||||
}
|
||||
if ( array_key_exists( 'species', $patch ) ) {
|
||||
$allowed_species = array( 'default' ); // Phase D will widen this.
|
||||
$sp = sanitize_key( (string) $patch['species'] );
|
||||
if ( in_array( $sp, $allowed_species, true ) ) {
|
||||
$state['species'] = $sp;
|
||||
}
|
||||
}
|
||||
|
||||
update_user_meta( $user_id, BUDDY_META_KEY, $state );
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Average of the four stats — used as a single "mood" indicator for
|
||||
* the dashboard widget. Returns 0–100.
|
||||
*/
|
||||
function buddy_overall_mood( array $state ) {
|
||||
$sum = (int) $state['hunger'] + (int) $state['happiness'] + (int) $state['health'] + (int) $state['energy'];
|
||||
return (int) round( $sum / 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a one-word emoji / status label based on overall mood. Pure
|
||||
* cosmetic, used in the dashboard widget header.
|
||||
*/
|
||||
function buddy_mood_label( $mood_score ) {
|
||||
if ( $mood_score >= 80 ) { return array( 'label' => __( 'Thriving', 'buddy' ), 'tone' => 'happy' ); }
|
||||
if ( $mood_score >= 60 ) { return array( 'label' => __( 'Content', 'buddy' ), 'tone' => 'happy' ); }
|
||||
if ( $mood_score >= 40 ) { return array( 'label' => __( 'Okay', 'buddy' ), 'tone' => 'neutral' ); }
|
||||
if ( $mood_score >= 20 ) { return array( 'label' => __( 'Hungry', 'buddy' ), 'tone' => 'sad' ); }
|
||||
return array( 'label' => __( 'Distressed', 'buddy' ), 'tone' => 'sad' );
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* Buddy — self-hosted update checker against the Gitea repo.
|
||||
*
|
||||
* Direct port of the Logbook v3.3.5 updater (proven in production).
|
||||
* Polls Gitea's /releases/latest, falls back to /tags?limit=1 when no
|
||||
* formal Release object exists, compares against BUDDY_VERSION,
|
||||
* renders an Updates panel on the Settings page. Cached 12h on
|
||||
* success, 1h on negative responses.
|
||||
*
|
||||
* Repo coordinates are constants you can override via define() in
|
||||
* wp-config.php if the repo ever moves.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
if ( ! defined( 'BUDDY_GITEA_HOST' ) ) { define( 'BUDDY_GITEA_HOST', 'https://git.davidtkeane.com' ); }
|
||||
if ( ! defined( 'BUDDY_GITEA_OWNER' ) ) { define( 'BUDDY_GITEA_OWNER', 'ranger' ); }
|
||||
if ( ! defined( 'BUDDY_GITEA_REPO' ) ) { define( 'BUDDY_GITEA_REPO', 'a-buddy' ); }
|
||||
|
||||
function buddy_gitea_repo_url() {
|
||||
return BUDDY_GITEA_HOST . '/' . BUDDY_GITEA_OWNER . '/' . BUDDY_GITEA_REPO;
|
||||
}
|
||||
function buddy_gitea_releases_url() {
|
||||
return buddy_gitea_repo_url() . '/releases';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release/tag, normalised. Returns null on hard
|
||||
* error, or an array including `version`. See Logbook updater for the
|
||||
* full shape.
|
||||
*/
|
||||
function buddy_fetch_latest_release( $force_refresh = false ) {
|
||||
$cache_key = 'buddy_gitea_latest';
|
||||
|
||||
if ( ! $force_refresh ) {
|
||||
$cached = get_site_transient( $cache_key );
|
||||
if ( is_array( $cached ) ) { return $cached; }
|
||||
}
|
||||
|
||||
$base_api = BUDDY_GITEA_HOST . '/api/v1/repos/' . BUDDY_GITEA_OWNER . '/' . BUDDY_GITEA_REPO;
|
||||
|
||||
// Try formal Release first.
|
||||
$response = wp_remote_get( $base_api . '/releases/latest', array( 'timeout' => 8 ) );
|
||||
if ( is_wp_error( $response ) ) { return null; }
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = ( $code === 200 ) ? json_decode( wp_remote_retrieve_body( $response ), true ) : null;
|
||||
|
||||
// Fallback to /tags if no Release object exists yet.
|
||||
if ( $code !== 200 || ! is_array( $body ) || empty( $body['tag_name'] ) ) {
|
||||
$tags_response = wp_remote_get( $base_api . '/tags?limit=1', array( 'timeout' => 8 ) );
|
||||
if ( ! is_wp_error( $tags_response )
|
||||
&& (int) wp_remote_retrieve_response_code( $tags_response ) === 200 ) {
|
||||
$tags = json_decode( wp_remote_retrieve_body( $tags_response ), true );
|
||||
if ( is_array( $tags ) && ! empty( $tags[0]['name'] ) ) {
|
||||
$body = array(
|
||||
'tag_name' => $tags[0]['name'],
|
||||
'html_url' => buddy_gitea_repo_url() . '/src/tag/' . rawurlencode( $tags[0]['name'] ),
|
||||
'body' => isset( $tags[0]['message'] ) ? $tags[0]['message'] : '',
|
||||
'published_at' => isset( $tags[0]['commit']['created'] ) ? $tags[0]['commit']['created'] : null,
|
||||
'assets' => array(),
|
||||
);
|
||||
$code = 200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $code !== 200 || ! is_array( $body ) || empty( $body['tag_name'] ) ) {
|
||||
$info = array(
|
||||
'version' => null,
|
||||
'html_url' => buddy_gitea_releases_url(),
|
||||
'download_url' => null,
|
||||
'body' => '',
|
||||
'published_at' => null,
|
||||
'error_code' => $code,
|
||||
);
|
||||
set_site_transient( $cache_key, $info, HOUR_IN_SECONDS );
|
||||
return $info;
|
||||
}
|
||||
|
||||
$version = ltrim( (string) $body['tag_name'], 'vV' );
|
||||
|
||||
// Prefer a .zip asset; fall back to Gitea source-archive URL.
|
||||
$download_url = null;
|
||||
if ( ! empty( $body['assets'] ) && is_array( $body['assets'] ) ) {
|
||||
foreach ( $body['assets'] as $asset ) {
|
||||
if ( isset( $asset['name'], $asset['browser_download_url'] )
|
||||
&& substr( strtolower( $asset['name'] ), -4 ) === '.zip' ) {
|
||||
$download_url = $asset['browser_download_url'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( ! $download_url ) {
|
||||
$download_url = buddy_gitea_repo_url() . '/archive/' . rawurlencode( $body['tag_name'] ) . '.zip';
|
||||
}
|
||||
|
||||
$info = array(
|
||||
'version' => $version,
|
||||
'html_url' => isset( $body['html_url'] ) ? esc_url_raw( $body['html_url'] ) : '',
|
||||
'download_url' => esc_url_raw( $download_url ),
|
||||
'body' => isset( $body['body'] ) ? wp_strip_all_tags( $body['body'] ) : '',
|
||||
'published_at' => isset( $body['published_at'] ) ? $body['published_at'] : null,
|
||||
);
|
||||
|
||||
set_site_transient( $cache_key, $info, 12 * HOUR_IN_SECONDS );
|
||||
return $info;
|
||||
}
|
||||
|
||||
function buddy_update_status( $force_refresh = false ) {
|
||||
$current = defined( 'BUDDY_VERSION' ) ? BUDDY_VERSION : '0.0.0';
|
||||
$latest = buddy_fetch_latest_release( $force_refresh );
|
||||
|
||||
if ( ! $latest || empty( $latest['version'] ) ) {
|
||||
$msg = __( 'No releases tagged on the Gitea repo yet.', 'buddy' );
|
||||
if ( $latest && ! empty( $latest['error_code'] ) && (int) $latest['error_code'] !== 404 ) {
|
||||
$msg = sprintf( __( 'Could not reach Gitea (HTTP %d). Try again in a few minutes.', 'buddy' ), (int) $latest['error_code'] );
|
||||
}
|
||||
return array(
|
||||
'status' => 'unknown',
|
||||
'current' => $current,
|
||||
'message' => $msg,
|
||||
'repo_url' => buddy_gitea_repo_url(),
|
||||
);
|
||||
}
|
||||
|
||||
if ( version_compare( $latest['version'], $current, '>' ) ) {
|
||||
return array(
|
||||
'status' => 'available',
|
||||
'current' => $current,
|
||||
'latest' => $latest['version'],
|
||||
'html_url' => $latest['html_url'],
|
||||
'download_url' => $latest['download_url'],
|
||||
'published_at' => $latest['published_at'],
|
||||
'body' => $latest['body'],
|
||||
'message' => sprintf( __( 'A new version (v%1$s) is available — you are on v%2$s.', 'buddy' ), $latest['version'], $current ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'up-to-date',
|
||||
'current' => $current,
|
||||
'latest' => $latest['version'],
|
||||
'message' => sprintf( __( 'You are up to date (v%s).', 'buddy' ), $current ),
|
||||
'repo_url' => buddy_gitea_repo_url(),
|
||||
);
|
||||
}
|
||||
|
||||
add_action( 'wp_ajax_buddy_check_updates', 'buddy_ajax_check_updates' );
|
||||
function buddy_ajax_check_updates() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Insufficient permissions.', 403 );
|
||||
}
|
||||
check_ajax_referer( 'buddy_check_updates', 'nonce' );
|
||||
delete_site_transient( 'buddy_gitea_latest' );
|
||||
wp_send_json_success( buddy_update_status( true ) );
|
||||
}
|
||||
|
||||
function buddy_render_updates_panel() {
|
||||
$status = buddy_update_status( false );
|
||||
$nonce = wp_create_nonce( 'buddy_check_updates' );
|
||||
$repo_url = buddy_gitea_repo_url();
|
||||
$rel_url = buddy_gitea_releases_url();
|
||||
?>
|
||||
<div class="buddy-updates" style="max-width:720px; margin-top:24px; padding:18px 20px; background:#fff; border:1px solid #ccd0d4; border-radius:4px;">
|
||||
<h2 style="margin-top:0;"><?php esc_html_e( 'Updates', 'buddy' ); ?></h2>
|
||||
<p style="margin:0 0 12px;">
|
||||
<?php esc_html_e( 'Buddy is self-hosted on Gitea. Click Check now to ask the repo whether there is a newer release than the one you are running.', 'buddy' ); ?>
|
||||
</p>
|
||||
|
||||
<p id="buddy-update-status" style="margin:0 0 12px;">
|
||||
<strong><?php esc_html_e( 'Status:', 'buddy' ); ?></strong>
|
||||
<span id="buddy-update-status-text"><?php echo esc_html( $status['message'] ); ?></span>
|
||||
<?php if ( $status['status'] === 'available' && ! empty( $status['download_url'] ) ) : ?>
|
||||
<br>
|
||||
<a href="<?php echo esc_url( $status['download_url'] ); ?>" class="button button-primary" style="margin-top:8px;">
|
||||
<?php
|
||||
/* translators: %s is the latest version number, e.g. "0.2.0" */
|
||||
echo esc_html( sprintf( __( 'Download v%s (.zip)', 'buddy' ), $status['latest'] ) );
|
||||
?>
|
||||
</a>
|
||||
<?php if ( ! empty( $status['html_url'] ) ) : ?>
|
||||
<a href="<?php echo esc_url( $status['html_url'] ); ?>" target="_blank" rel="noopener" style="margin-left:8px;"><?php esc_html_e( 'View release notes →', 'buddy' ); ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 4px;">
|
||||
<button type="button" id="buddy-check-updates-btn" class="button" data-nonce="<?php echo esc_attr( $nonce ); ?>">
|
||||
↻ <?php esc_html_e( 'Check now', 'buddy' ); ?>
|
||||
</button>
|
||||
<a href="<?php echo esc_url( $repo_url ); ?>" target="_blank" rel="noopener" class="button" style="margin-left:6px;"><?php esc_html_e( 'View on Gitea', 'buddy' ); ?></a>
|
||||
<a href="<?php echo esc_url( $rel_url ); ?>" target="_blank" rel="noopener" class="button" style="margin-left:6px;"><?php esc_html_e( 'View all releases', 'buddy' ); ?></a>
|
||||
</p>
|
||||
<p style="margin:10px 0 0; color:#646970; font-size:12px;">
|
||||
<?php esc_html_e( 'Manual update path: download the .zip, deactivate the plugin in WordPress, upload via Plugins → Add New → Upload, reactivate. Your Buddy survives the upgrade (state is stored in user_meta).', 'buddy' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('buddy-check-updates-btn');
|
||||
var statusText = document.getElementById('buddy-update-status-text');
|
||||
if (!btn || !statusText) { return; }
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var nonce = btn.getAttribute('data-nonce');
|
||||
btn.disabled = true;
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = '↻ Checking…';
|
||||
statusText.textContent = 'Asking Gitea…';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('action', 'buddy_check_updates');
|
||||
fd.append('nonce', nonce);
|
||||
|
||||
fetch(ajaxurl, { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res || !res.success) {
|
||||
statusText.textContent = (res && res.data) ? String(res.data) : 'Check failed.';
|
||||
} else {
|
||||
statusText.textContent = res.data.message || 'Check complete.';
|
||||
if (res.data.status === 'available' && res.data.download_url) {
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () { statusText.textContent = 'Network error — try again in a moment.'; })
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
Reference in New Issue
Block a user