add_action( 'add_meta_boxes', $this, 'add_meta_boxes' ); $loader->add_action( 'save_post_academiccrew_member', $this, 'save_meta', 10, 2 ); $loader->add_filter( 'manage_academiccrew_member_posts_columns', $this, 'register_columns' ); $loader->add_filter( 'manage_edit-academiccrew_member_sortable_columns', $this, 'sortable_columns' ); $loader->add_action( 'manage_academiccrew_member_posts_custom_column', $this, 'render_column', 10, 2 ); $loader->add_action( 'pre_get_posts', $this, 'sort_by_column' ); $loader->add_action( 'admin_post_acc_generate_member_page', $this, 'handle_generate_page' ); $loader->add_action( 'wp_ajax_acc_sync_member_pubs', $this, 'ajax_sync_member' ); $loader->add_action( 'wp_ajax_acc_sync_member_status', $this, 'ajax_sync_member_status' ); $loader->add_action( 'admin_enqueue_scripts', $this, 'enqueue_member_scripts' ); $loader->add_action( 'wp_ajax_acc_get_research_areas', $this, 'ajax_get_research_areas' ); $loader->add_action( 'wp_ajax_acc_search_publications', $this, 'ajax_search_publications' ); $loader->add_action( 'edit_form_after_title', $this, 'render_quick_profile_bar' ); $loader->add_action( 'admin_notices', $this, 'show_user_created_notice' ); } public function enqueue_member_scripts( string $hook ): void { global $post; if ( ! in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) return; if ( ! $post || $post->post_type !== 'academiccrew_member' ) return; wp_enqueue_media(); } public function render_quick_profile_bar( \WP_Post $post ): void { if ( 'academiccrew_member' !== $post->post_type ) return; $first_name = get_post_meta( $post->ID, '_acc_first_name', true ); $last_name = get_post_meta( $post->ID, '_acc_last_name', true ); $role = get_post_meta( $post->ID, '_acc_member_role', true ); $role_rank = (int) ( get_post_meta( $post->ID, '_acc_role_rank', true ) ?: 0 ); $roles = RoleRegistry::all(); ?>
prefix . 'academiccrew_publications' ); $like = '%' . $wpdb->esc_like( $q ) . '%'; if ( $member_id ) { $rel = esc_sql( $wpdb->prefix . 'academiccrew_member_publications' ); $results = $wpdb->get_results( $wpdb->prepare( "SELECT p.id, p.title, p.year, p.doi FROM {$table} p INNER JOIN {$rel} mp ON mp.publication_id = p.id AND mp.member_id = %d WHERE p.title LIKE %s ORDER BY p.year DESC LIMIT 15", $member_id, $like ) ); } else { $results = $wpdb->get_results( $wpdb->prepare( "SELECT id, title, year, doi FROM {$table} WHERE title LIKE %s ORDER BY year DESC LIMIT 15", $like ) ); } wp_send_json_success( $results ?: [] ); } public function ajax_get_research_areas(): void { if ( ! current_user_can( 'edit_posts' ) || ! check_ajax_referer( 'acc_member_meta', '_wpnonce', false ) ) { wp_send_json_error(); } $q = strtolower( sanitize_text_field( wp_unslash( $_GET['q'] ?? '' ) ) ); $members = get_posts( [ 'post_type' => 'academiccrew_member', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ] ); $areas = []; foreach ( $members as $mid ) { $raw = get_post_meta( (int) $mid, '_acc_research_areas', true ); $list = is_array( $raw ) ? $raw : []; foreach ( $list as $a ) { $a = ucwords( trim( (string) $a ) ); if ( $a ) $areas[ $a ] = true; } } $areas = array_keys( $areas ); if ( $q ) { $areas = array_values( array_filter( $areas, fn( $a ) => str_contains( strtolower( $a ), $q ) ) ); } sort( $areas ); wp_send_json_success( $areas ); } // ------------------------------------------------------------------------- // Meta boxes // ------------------------------------------------------------------------- public function add_meta_boxes(): void { $cpt = 'academiccrew_member'; add_meta_box( 'acc_member_profile', __( 'Member Profile', 'academiccrew' ), [ $this, 'render_profile_box' ], $cpt, 'normal', 'high' ); add_meta_box( 'acc_member_contact', __( 'Contact Information', 'academiccrew' ), [ $this, 'render_contact_box' ], $cpt, 'normal', 'default' ); add_meta_box( 'acc_member_institutional', __( 'Institutional Links', 'academiccrew' ), [ $this, 'render_institutional_box' ], $cpt, 'normal', 'default' ); add_meta_box( 'acc_member_research_ids', __( 'Research Identifiers', 'academiccrew' ), [ $this, 'render_research_ids_box' ], $cpt, 'normal', 'default' ); add_meta_box( 'acc_member_social', __( 'Social & Web', 'academiccrew' ), [ $this, 'render_social_box' ], $cpt, 'normal', 'default' ); add_meta_box( 'acc_member_stats', __( 'Publication Statistics', 'academiccrew' ), [ $this, 'render_stats_box' ], $cpt, 'normal', 'low' ); add_meta_box( 'acc_member_linked_page', __( 'Profile Page', 'academiccrew' ), [ $this, 'render_linked_page_box' ], $cpt, 'normal', 'low' ); add_meta_box( 'acc_member_service_roles', __( 'Service Roles', 'academiccrew' ), [ $this, 'render_service_roles_box' ], $cpt, 'normal', 'low' ); add_meta_box( 'acc_member_awards', __( 'Awards & Distinctions', 'academiccrew' ), [ $this, 'render_awards_box' ], $cpt, 'normal', 'low' ); add_meta_box( 'acc_member_featured_pubs', __( 'Featured Publications', 'academiccrew' ), [ $this, 'render_featured_pubs_box' ], $cpt, 'normal', 'low' ); } // ------------------------------------------------------------------------- // Render: Member Profile // ------------------------------------------------------------------------- public function render_profile_box( \WP_Post $post ): void { wp_nonce_field( 'acc_member_meta_save', '_acc_member_nonce' ); $department = get_post_meta( $post->ID, '_acc_department', true ); $affiliation = get_post_meta( $post->ID, '_acc_affiliation', true ); $bio = get_post_meta( $post->ID, '_acc_bio', true ); $avatar_url = get_post_meta( $post->ID, '_acc_avatar_url', true ); $raw_areas = get_post_meta( $post->ID, '_acc_research_areas', true ); $research_areas = implode( ', ', (array) ( maybe_unserialize( $raw_areas ) ?: [] ) ); ?>
|
|
|
| 'acc_bio', 'media_buttons' => false, 'textarea_rows' => 6, 'teeny' => true, 'quicktags' => [ 'buttons' => 'strong,em,link,ul,ol,li' ], ] ); ?> | |
|
|
|
|
|
|
|
%s
', esc_html__( 'No profile page found. Publish the member and click Generate, or just re-save.', 'academiccrew' ), esc_url( $generate_url ), esc_html__( 'Generate profile page', 'academiccrew' ) ); return; } $page_url = get_permalink( $page_id ); $edit_url = get_edit_post_link( $page_id ); ?>
|
ID ); ?>
|
|
sync_status_payload( $mid ); ob_start(); ?> (function () { var mid = ; var nonce = ; var btn = document.getElementById('acc-sync-btn-' + mid); if (!btn) return; var label = btn.querySelector('.acc-sync-label'); var spinner = btn.querySelector('.spinner'); var result = document.getElementById('acc-sync-result-' + mid); var idleLbl = ; var busyLbl = ; var lost = ; var started = 0, timer = null, misses = 0, beforeId = 0; function call(action) { var fd = new FormData(); fd.append('action', action); fd.append('member_id', mid); fd.append('_wpnonce', nonce); return fetch(ajaxurl, { method: 'POST', body: fd, credentials: 'same-origin' }) .then(function (r) { return r.json(); }); } function elapsed() { var s = Math.round((Date.now() - started) / 1000); return ' (' + Math.floor(s / 60) + ':' + ('0' + s % 60).slice(-2) + ')'; } function show(d) { if (!d) return; if (d.pub_count !== undefined) { var pc = document.getElementById('acc-pub-count-' + mid); if (pc) pc.textContent = d.pub_count || '—'; } if (d.last_sync) { var ls = document.getElementById('acc-last-sync-' + mid); if (ls) ls.textContent = d.last_sync; } // Right after the click the job may not have written its result yet: // the previous result is not this sync's, keep waiting a little. if (d.done && beforeId && d.result && d.result.id === beforeId && Date.now() - started < 30000) { d = Object.assign({}, d, { done: false, message: busyLbl }); } if (d.done) { clearTimeout(timer); timer = null; btn.disabled = false; label.textContent = idleLbl; spinner.style.display = 'none'; result.style.color = d.failed ? '#a00' : '#3c763d'; result.textContent = d.message; } else { btn.disabled = true; label.textContent = busyLbl; spinner.style.display = 'inline-block'; result.style.color = '#646970'; result.textContent = d.message + elapsed(); timer = setTimeout(poll, 5000); } } function poll() { call('acc_sync_member_status') .then(function (resp) { misses = 0; if (resp.success) show(resp.data); }) .catch(function () { // A slow or dropped poll is not a failed sync: keep trying for a while. if (++misses < 6) { timer = setTimeout(poll, 10000); return; } btn.disabled = false; label.textContent = idleLbl; spinner.style.display = 'none'; result.style.color = '#a00'; result.textContent = lost; }); } btn.addEventListener('click', function () { started = Date.now(); misses = 0; btn.disabled = true; label.textContent = busyLbl; spinner.style.display = 'inline-block'; result.textContent = ''; call('acc_sync_member_pubs') .then(function (resp) { if (!resp.success) { btn.disabled = false; label.textContent = idleLbl; spinner.style.display = 'none'; result.style.color = '#a00'; result.textContent = (resp.data && resp.data.message) || 'Error'; return; } beforeId = resp.data.result ? resp.data.result.id : 0; resp.data.done = false; // it just started: keep polling show(resp.data); }) .catch(function () { poll(); }); }); // Opened while a sync of this member is queued or running: follow it. var initial = ; if (!initial.done) { started = Date.now(); show(initial); } })(); ID, '_acc_service_roles', true ); $roles = is_array( $raw ) ? $raw : []; $role_types = [ 'chair' => __( 'Chair', 'academiccrew' ), 'area-chair' => __( 'Area Chair', 'academiccrew' ), 'senior-pc' => __( 'Senior PC', 'academiccrew' ), 'tpc' => __( 'TPC', 'academiccrew' ), 'organiser' => __( 'Organiser', 'academiccrew' ), 'editor' => __( 'Editor', 'academiccrew' ), ]; ?>
$entry ) : // Backwards compat: old schema used role_type / event_name / year. $role_val = $entry['role'] ?? $entry['role_type'] ?? 'tpc'; $venue_val = $entry['venue'] ?? $entry['event_name'] ?? ''; $track_val = $entry['track'] ?? ''; $url_val = $entry['url'] ?? ''; $yfrom_val = $entry['year_from'] ?? $entry['year'] ?? ''; $yto_val = $entry['year_to'] ?? ''; ?>
$entry ) : echo $row_html( $idx, $entry['type'] ?? 'other', $entry['custom_title'] ?? $entry['title'] ?? '', $entry['paper_title'] ?? '', (int) ( $entry['publication_id'] ?? 0 ), $entry['venue'] ?? $entry['organization'] ?? '', $entry['year'] ?? '', $entry['coauthors'] ?? '', $entry['url'] ?? '' ); endforeach; ?>
(function () { 'use strict'; var MID = ; var NONCE = ; var TAGS = ; var list = document.getElementById('acc-fp-list'); var emptyMsg = document.getElementById('acc-fp-empty'); var search = document.getElementById('acc-fp-search'); var ac = document.getElementById('acc-fp-ac'); var debounce; var dragSrc = null; function currentIds() { return Array.from(list.querySelectorAll('input[name="acc_featured_pub_ids[]"]')) .map(function (i) { return parseInt(i.value, 10); }); } function clearDropIndicators() { list.querySelectorAll('.acc-fp-row').forEach(function (r) { r.style.borderTop = r.style.borderBottom = ''; }); } function buildTagsHtml(pubId) { if (!TAGS.length) return ''; var html = ''; return html; } function bindRow(row) { row.querySelector('.acc-fp-remove').addEventListener('click', function () { row.remove(); if (!list.querySelector('.acc-fp-row')) emptyMsg.style.display = ''; }); row.addEventListener('dragstart', function (e) { dragSrc = row; e.dataTransfer.effectAllowed = 'move'; setTimeout(function () { row.style.opacity = '0.4'; }, 0); }); row.addEventListener('dragend', function () { row.style.opacity = ''; clearDropIndicators(); dragSrc = null; }); row.addEventListener('dragover', function (e) { e.preventDefault(); if (!dragSrc || dragSrc === row) return; clearDropIndicators(); var mid_y = row.getBoundingClientRect().top + row.offsetHeight / 2; if (e.clientY < mid_y) row.style.borderTop = '2px solid #0073aa'; else row.style.borderBottom = '2px solid #0073aa'; }); row.addEventListener('dragleave', function () { row.style.borderTop = row.style.borderBottom = ''; }); row.addEventListener('drop', function (e) { e.preventDefault(); if (!dragSrc || dragSrc === row) return; clearDropIndicators(); var mid_y = row.getBoundingClientRect().top + row.offsetHeight / 2; if (e.clientY < mid_y) list.insertBefore(dragSrc, row); else row.after(dragSrc); }); } function makeRow(id, title, year) { var row = document.createElement('div'); row.className = 'acc-fp-row'; row.draggable = true; row.style.cssText = 'padding:6px 8px;margin-bottom:6px;background:#f9f9f9;border:1px solid #ddd;border-radius:4px'; var yearHtml = year ? ' (' + year + ')' : ''; row.innerHTML = '
%s', esc_html( $id ) );
} else {
echo '—';
}
break;
case 'acc_pubs':
echo esc_html( get_post_meta( $post_id, '_acc_pub_count', true ) ?: '—' );
break;
case 'acc_page':
$page_id = $this->get_linked_page_id( $post_id );
if ( $page_id ) {
printf(
'%s %s',
esc_url( get_permalink( $page_id ) ),
esc_attr__( 'View profile page', 'academiccrew' ),
esc_html( get_permalink( $page_id ) ),
esc_url( get_edit_post_link( $page_id ) ),
esc_html__( 'Edit', 'academiccrew' )
);
} else {
echo '' . esc_html__( 'Not created yet', 'academiccrew' ) . '';
}
break;
case 'acc_sync':
$sync = get_post_meta( $post_id, '_academiccrew_last_sync', true );
echo $sync
? esc_html( wp_date( 'Y-m-d H:i', strtotime( $sync ) ) )
: '—';
break;
}
}
public function sortable_columns( array $columns ): array {
$columns['acc_role'] = 'acc_role';
$columns['acc_dept'] = 'acc_dept';
$columns['acc_pubs'] = 'acc_pubs';
$columns['acc_sync'] = 'acc_sync';
return $columns;
}
public function sort_by_column( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( ( $query->get( 'post_type' ) ) !== 'academiccrew_member' ) {
return;
}
$map = [
'acc_role' => [ '_acc_member_role', 'CHAR', 'ASC' ],
'acc_dept' => [ '_acc_department', 'CHAR', 'ASC' ],
'acc_pubs' => [ '_acc_pub_count', 'NUMERIC', 'DESC' ],
'acc_sync' => [ '_academiccrew_last_sync', 'CHAR', 'DESC' ],
];
$orderby = $query->get( 'orderby' );
if ( ! isset( $map[ $orderby ] ) ) {
return;
}
[ $meta_key, $type, $default_order ] = $map[ $orderby ];
$query->set( 'meta_key', $meta_key );
$query->set( 'orderby', 'meta_value' . ( $type === 'NUMERIC' ? '_num' : '' ) );
// Respect explicit order from the URL; fall back to the column default.
if ( ! in_array( strtoupper( $query->get( 'order' ) ), [ 'ASC', 'DESC' ], true ) ) {
$query->set( 'order', $default_order );
}
}
// -------------------------------------------------------------------------
// Linked page helpers
// -------------------------------------------------------------------------
/**
* Returns the linked WP profile page ID, auto-repairing a stale pointer.
* Returns 0 when no live page exists.
*/
private function get_linked_page_id( int $member_id ): int {
$page_id = (int) get_post_meta( $member_id, '_academiccrew_linked_page_id', true );
if ( $page_id && get_post( $page_id ) ) {
return $page_id;
}
// Meta is missing or stale — try to recover by scanning for the page.
$found = get_posts( [
'post_type' => 'page',
'post_status' => [ 'publish', 'draft', 'private' ],
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => '_academiccrew_member_id',
'meta_value' => $member_id,
] );
if ( $found ) {
$recovered = (int) $found[0];
update_post_meta( $member_id, '_academiccrew_linked_page_id', $recovered );
return $recovered;
}
return 0;
}
/**
* "Sync publications now": start the sync in the background and return at
* once — it takes minutes, longer than browsers / proxies keep an AJAX
* request open. The page then polls ajax_sync_member_status().
*/
public function ajax_sync_member(): void {
$member_id = $this->sync_request_member();
$how = \AcademicCrew\Sync\SyncScheduler::start_member_sync( $member_id );
wp_send_json_success( array_merge(
$this->sync_status_payload( $member_id ),
[ 'started' => $how ]
) );
}
/** Polled by the admin while a member sync is queued or running. */
public function ajax_sync_member_status(): void {
$member_id = $this->sync_request_member();
$payload = $this->sync_status_payload( $member_id );
if ( 'queued' === $payload['state'] ) {
spawn_cron(); // a waiting job starts even on a site with no visitors
}
wp_send_json_success( $payload );
}
private function sync_request_member(): int {
$member_id = (int) ( $_POST['member_id'] ?? 0 );
if ( ! $member_id
|| ! check_ajax_referer( 'acc_sync_member_' . $member_id, '_wpnonce', false )
|| ! current_user_can( 'edit_post', $member_id ) ) {
wp_send_json_error( [ 'message' => __( 'Not allowed.', 'academiccrew' ) ] );
}
if ( ! Freemius::is_pro() ) {
wp_send_json_error( [ 'message' => __( 'Automatic publication sync is a Pro feature.', 'academiccrew' ) ] );
}
return $member_id;
}
/** Status + a ready-to-show message (also used to render the box). */
public function sync_status_payload( int $member_id ): array {
$st = \AcademicCrew\Sync\SyncScheduler::member_sync_status( $member_id );
$r = $st['result'];
switch ( $st['state'] ) {
case 'queued':
$msg = __( 'Sync scheduled — starting in a moment…', 'academiccrew' );
break;
case 'running':
$msg = __( 'Syncing publications… this can take several minutes. You can leave this page: the result will show here and in the Sync Log.', 'academiccrew' );
break;
case 'in_full_sync':
$msg = __( 'A sync of all members is running: this member is at the front of its queue and will be updated in a few minutes.', 'academiccrew' );
break;
default:
if ( ! $r ) {
$msg = '';
} elseif ( 'error' === $r['status'] ) {
/* translators: %s: error message */
$msg = sprintf( __( 'Last sync failed: %s', 'academiccrew' ), $r['error'] ?: __( 'see the Sync Log', 'academiccrew' ) );
} else {
/* translators: 1: new, 2: updated, 3: skipped publications */
$msg = sprintf( __( 'Done — %1$d new, %2$d updated, %3$d unchanged.', 'academiccrew' ), $r['new'], $r['updated'], $r['skipped'] );
}
}
$st['done'] = 'idle' === $st['state'];
$st['failed'] = $st['done'] && $r && 'error' === $r['status'];
$st['message'] = $msg;
return $st;
}
// -------------------------------------------------------------------------
// Shared helper: linked-page help notice
// -------------------------------------------------------------------------
private function render_page_help( string $auto_text, string $edit_text ): void {
?>
[] ] ); ?>
%s