calculator = new AcademicYearCalculator();
}
public function register( Loader $loader ): void {
$loader->add_action( 'add_meta_boxes', $this, 'add_meta_boxes' );
$loader->add_action( 'save_post_academiccrew_course', $this, 'save_meta', 10, 2 );
$loader->add_filter( 'manage_academiccrew_course_posts_columns', $this, 'register_columns' );
$loader->add_action( 'manage_academiccrew_course_posts_custom_column', $this, 'render_column', 10, 2 );
$loader->add_filter( 'manage_edit-academiccrew_course_sortable_columns', $this, 'sortable_columns' );
}
// -------------------------------------------------------------------------
// Meta boxes
// -------------------------------------------------------------------------
public function add_meta_boxes(): void {
$cpt = 'academiccrew_course';
add_meta_box( 'acc_course_details', __( 'Course Details', 'academiccrew' ),
[ $this, 'render_details_box' ], $cpt, 'normal', 'high' );
add_meta_box( 'acc_course_staff', __( 'Teaching Staff', 'academiccrew' ),
[ $this, 'render_staff_box' ], $cpt, 'normal', 'default' );
add_meta_box( 'acc_course_content', __( 'Course Content', 'academiccrew' ),
[ $this, 'render_content_box' ], $cpt, 'normal', 'default' );
add_meta_box( 'acc_course_resources', __( 'Resources', 'academiccrew' ),
[ $this, 'render_resources_box' ], $cpt, 'normal', 'low' );
add_meta_box( 'acc_course_linked_page', __( 'Profile Page', 'academiccrew' ),
[ $this, 'render_linked_page_box' ], $cpt, 'normal', 'low' );
}
// -------------------------------------------------------------------------
// Render: Course Details
// -------------------------------------------------------------------------
public function render_details_box( \WP_Post $post ): void {
wp_nonce_field( 'acc_course_meta_save', '_acc_course_nonce' );
$codes = (array) ( get_post_meta( $post->ID, '_acc_course_codes', true ) ?: [] );
// Migrate from old single-code meta if needed.
if ( empty( $codes ) ) {
$old_code = get_post_meta( $post->ID, '_acc_course_code', true );
if ( $old_code ) {
$codes = [ $old_code ];
}
}
$credits = get_post_meta( $post->ID, '_acc_credits', true );
$lecture_hours = get_post_meta( $post->ID, '_acc_lecture_hours', true );
$degree = get_post_meta( $post->ID, '_acc_degree_program', true );
$language = get_post_meta( $post->ID, '_acc_language', true ) ?: 'IT';
$period = (int) ( get_post_meta( $post->ID, '_acc_period', true ) ?: 1 );
$acad_year = get_post_meta( $post->ID, '_acc_academic_year', true );
$year_override = get_post_meta( $post->ID, '_acc_year_override', true );
$level = get_post_meta( $post->ID, '_acc_level', true );
$featured = get_post_meta( $post->ID, '_acc_featured', true );
$featured_order = get_post_meta( $post->ID, '_acc_featured_order', true );
$archived = get_post_meta( $post->ID, '_acc_archived', true );
$period_count = PeriodCadence::period_count();
$years_by_period = [];
for ( $p = 1; $p <= $period_count; $p++ ) {
$years_by_period[ $p ] = $this->calculator->calculate( $p );
}
$use_stored = $year_override || $archived;
$display_year = $use_stored ? (int) $acad_year : $this->calculator->calculate( $period );
?>
(function () {
'use strict';
var yearByPeriod = ;
var periodSel = document.getElementById('acc_period');
var overrideCb = document.getElementById('acc_year_override');
var manualSec = document.getElementById('acc_year_manual_section');
var preview = document.getElementById('acc_year_preview');
function formatAY(y) {
return 'A.Y. ' + y + '/' + String(y + 1).slice(-2);
}
function updatePreview() {
if (overrideCb.checked) return;
var p = parseInt(periodSel.value, 10);
preview.textContent = formatAY(yearByPeriod[p] || yearByPeriod[1]);
}
periodSel.addEventListener('change', updatePreview);
overrideCb.addEventListener('change', function () {
manualSec.style.display = this.checked ? 'block' : 'none';
if (!this.checked) updatePreview();
});
}());
ID, '_acc_instructors', true );
$instructors = is_array( $raw_instructors ) ? $raw_instructors : [];
$members = get_posts( [
'post_type' => 'academiccrew_member',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
'fields' => 'ids',
] );
$instructor_roles = [
'teacher' => __( 'Teacher', 'academiccrew' ),
'teaching-assistant' => __( 'Teaching Assistant', 'academiccrew' ),
'coordinator' => __( 'Coordinator', 'academiccrew' ),
];
?>
+
ID, '_acc_past_instructors', true );
$past = is_array( $past ) ? $past : [];
if ( $past ) : ?>
$rlabel ) : ?>
(function () {
'use strict';
var nextIdx = ;
var list = document.getElementById('acc_staff_list');
var tpl = document.getElementById('acc_staff_row_tpl');
var addBtn = document.getElementById('acc_add_staff');
var emptyMsg = document.getElementById('acc_staff_empty');
function removeRow(btn) {
btn.closest('.acc-staff-row').remove();
if (!list.querySelector('.acc-staff-row')) {
if (emptyMsg) {
list.appendChild(emptyMsg);
}
}
}
list.querySelectorAll('.acc-remove-staff').forEach(function (btn) {
btn.addEventListener('click', function () { removeRow(this); });
});
addBtn.addEventListener('click', function () {
if (emptyMsg && emptyMsg.parentNode === list) {
list.removeChild(emptyMsg);
}
var html = tpl.innerHTML.replace(/__IDX__/g, nextIdx++);
var tmp = document.createElement('div');
tmp.innerHTML = html;
var row = tmp.firstElementChild;
row.querySelector('.acc-remove-staff').addEventListener('click', function () {
removeRow(this);
});
list.appendChild(row);
});
}());
ID, '_acc_abstract', true ) ?: '';
$outcomes_raw = get_post_meta( $post->ID, '_acc_learning_outcomes', true );
$outcomes = is_array( $outcomes_raw ) ? $outcomes_raw : [];
$topics_raw = get_post_meta( $post->ID, '_acc_topics', true );
$topics = is_array( $topics_raw ) ? $topics_raw : [];
$prereq_raw = get_post_meta( $post->ID, '_acc_prerequisites', true );
$prereq = is_array( $prereq_raw ) ? $prereq_raw : [];
$assessment = get_post_meta( $post->ID, '_acc_assessment', true ) ?: '';
$schedule_raw = get_post_meta( $post->ID, '_acc_schedule', true );
$schedule = is_array( $schedule_raw ) ? $schedule_raw : [];
$reading_raw = get_post_meta( $post->ID, '_acc_reading_list', true );
$reading = is_array( $reading_raw ) ? $reading_raw : [];
$prereq_text = $prereq['text'] ?? '';
$prereq_tags_arr = $prereq['tags'] ?? [];
$prereq_tags_str = is_array( $prereq_tags_arr ) ? implode( ', ', $prereq_tags_arr ) : '';
$completeness = (int) (bool) $abstract
+ (int) ( ! empty( $outcomes ) )
+ (int) ( ! empty( $topics ) )
+ (int) ( $prereq_text || $prereq_tags_str )
+ (int) (bool) $assessment
+ (int) ( ! empty( $schedule ) )
+ (int) ( ! empty( $reading ) );
$has_content = $completeness > 0;
$h4 = 'style="margin:1.25rem 0 .4rem;font-size:13px;border-top:1px solid #eee;padding-top:1rem"';
$h4_first = 'style="margin:0 0 .4rem;font-size:13px"';
$day_opts = [
'Mon' => __( 'Monday', 'academiccrew' ),
'Tue' => __( 'Tuesday', 'academiccrew' ),
'Wed' => __( 'Wednesday', 'academiccrew' ),
'Thu' => __( 'Thursday', 'academiccrew' ),
'Fri' => __( 'Friday', 'academiccrew' ),
];
$build_day_select = function ( string $name, string $selected_val ) use ( $day_opts ): string {
$opts = '';
foreach ( $day_opts as $val => $label ) {
$sel = selected( $selected_val, $val, false );
$opts .= "" . esc_html( $label ) . ' ';
}
return "{$opts} ";
};
?>
✓
·
:
/7
—
✓
style="margin-top:4px">
—
ID, '_acc_syllabus_url', true );
$moodle = get_post_meta( $post->ID, '_acc_moodle_url', true );
?>
ID, '_academiccrew_linked_page_id', true );
if ( ! $page_id || ! get_post( $page_id ) ) {
echo ''
. esc_html__( 'The public page will be created automatically when this course is first published.', 'academiccrew' )
. '
';
$this->render_page_help(
__( 'The course header (code, credits, teaching staff, links) is built automatically from the fields on this screen.', 'academiccrew' ),
__( 'To add course content (slides, announcements, recordings, etc.), write it in the editor at the top of this screen — it will be synced to the public page on first publish.', 'academiccrew' )
);
return;
}
$page_url = get_permalink( $page_id );
$edit_url = get_edit_post_link( $page_id );
?>
render_page_help(
__( 'The course header (code, credits, teaching staff, links) is built automatically from the fields on this screen.', 'academiccrew' ),
__( 'To add course content (slides, announcements, recordings, etc.), write it in the editor at the top of this screen — it is synced to the public page automatically on every save.', 'academiccrew' )
); ?>
post_type ) {
return;
}
// Course codes — store array; keep _acc_course_code = first entry for lookups.
$raw_codes = array_map( 'wp_unslash', (array) ( $_POST['acc_course_codes'] ?? [] ) );
$codes = array_values( array_filter( array_map(
fn( $c ) => strtoupper( sanitize_text_field( $c ) ),
$raw_codes
) ) );
update_post_meta( $post_id, '_acc_course_codes', $codes );
update_post_meta( $post_id, '_acc_course_code', $codes[0] ?? '' );
// Credits — 1-30.
$credits = max( 1, min( 30, (int) ( $_POST['acc_credits'] ?? 6 ) ) );
update_post_meta( $post_id, '_acc_credits', $credits );
// Lecture hours — 0-500.
$lh = max( 0, min( 500, (int) ( $_POST['acc_lecture_hours'] ?? 0 ) ) );
update_post_meta( $post_id, '_acc_lecture_hours', $lh ?: '' );
// Degree programme.
update_post_meta( $post_id, '_acc_degree_program',
sanitize_text_field( wp_unslash( $_POST['acc_degree_program'] ?? '' ) ) );
// Language — IT or EN only. sanitize_key would lowercase, so use sanitize_text_field.
$lang_raw = sanitize_text_field( wp_unslash( $_POST['acc_language'] ?? '' ) );
$language = in_array( $lang_raw, [ 'IT', 'EN' ], true ) ? $lang_raw : 'IT';
update_post_meta( $post_id, '_acc_language', $language );
// Period — within the currently configured cadence's range.
$posted_period = (int) ( $_POST['acc_period'] ?? 1 );
$period = ( $posted_period >= 1 && $posted_period <= PeriodCadence::period_count() )
? $posted_period
: 1;
update_post_meta( $post_id, '_acc_period', $period );
// Archived flag — freezes the academic year display.
$archived = ! empty( $_POST['acc_archived'] );
update_post_meta( $post_id, '_acc_archived', $archived ? '1' : '' );
// Academic year — calculated or manually overridden.
$year_override = ! empty( $_POST['acc_year_override'] );
update_post_meta( $post_id, '_acc_year_override', $year_override ? '1' : '' );
if ( $year_override && ! empty( $_POST['acc_academic_year_manual'] ) ) {
$acad_year = (int) $_POST['acc_academic_year_manual'];
} else {
$acad_year = $this->calculator->calculate( $period );
}
update_post_meta( $post_id, '_acc_academic_year', $acad_year );
// Instructors — validated [{member_id, role}] array; rows with no member
// but a name are external instructors [{member_id:0, name, url, role}].
$valid_roles = [ 'teacher', 'teaching-assistant', 'coordinator' ];
$instructors = [];
foreach ( (array) ( $_POST['acc_instructors'] ?? [] ) as $entry ) {
$mid = (int) ( $entry['member_id'] ?? 0 );
$role = sanitize_key( $entry['role'] ?? 'teacher' );
if ( ! in_array( $role, $valid_roles, true ) ) {
continue;
}
$name = sanitize_text_field( wp_unslash( $entry['name'] ?? '' ) );
if ( $mid > 0 && 'academiccrew_member' === get_post_type( $mid ) ) {
$instructors[] = [ 'member_id' => $mid, 'role' => $role ];
} elseif ( $name !== '' ) {
$instructors[] = [
'member_id' => 0,
'name' => $name,
'url' => esc_url_raw( wp_unslash( $entry['url'] ?? '' ) ),
'role' => $role,
];
}
}
update_post_meta( $post_id, '_acc_instructors', $instructors );
// Course level.
$level = sanitize_key( wp_unslash( $_POST['acc_level'] ?? '' ) );
$allowed_levels = [ 'bsc', 'msc', 'phd', 'professional', 'other', '' ];
update_post_meta( $post_id, '_acc_level',
in_array( $level, $allowed_levels, true ) ? $level : '' );
// Featured flag + order (1–998 = explicit; 0/blank → 999 default).
update_post_meta( $post_id, '_acc_featured', isset( $_POST['acc_featured'] ) ? '1' : '' );
$fo = (int) ( $_POST['acc_featured_order'] ?? 0 );
update_post_meta( $post_id, '_acc_featured_order', (string) ( $fo >= 1 && $fo <= 999 ? $fo : 999 ) );
// Resource URLs.
update_post_meta( $post_id, '_acc_syllabus_url',
esc_url_raw( wp_unslash( $_POST['acc_syllabus_url'] ?? '' ) ) );
update_post_meta( $post_id, '_acc_moodle_url',
esc_url_raw( wp_unslash( $_POST['acc_moodle_url'] ?? '' ) ) );
// Optional content fields.
update_post_meta( $post_id, '_acc_abstract',
sanitize_textarea_field( wp_unslash( $_POST['acc_abstract'] ?? '' ) ) );
update_post_meta( $post_id, '_acc_assessment',
sanitize_textarea_field( wp_unslash( $_POST['acc_assessment'] ?? '' ) ) );
$raw_outcomes = sanitize_textarea_field( wp_unslash( $_POST['acc_learning_outcomes'] ?? '' ) );
$outcomes = array_values( array_filter(
array_map( 'trim', explode( "\n", str_replace( "\r", '', $raw_outcomes ) ) )
) );
update_post_meta( $post_id, '_acc_learning_outcomes', $outcomes );
$raw_topics = (array) ( $_POST['acc_topics'] ?? [] );
$topics = [];
foreach ( $raw_topics as $entry ) {
$num = sanitize_text_field( wp_unslash( $entry['num'] ?? '' ) );
$name = sanitize_text_field( wp_unslash( $entry['name'] ?? '' ) );
if ( $name !== '' ) {
$topics[] = [ 'num' => $num, 'name' => $name ];
}
}
update_post_meta( $post_id, '_acc_topics', $topics );
$prereq_text = sanitize_textarea_field( wp_unslash( $_POST['acc_prerequisites_text'] ?? '' ) );
$raw_tags = sanitize_text_field( wp_unslash( $_POST['acc_prerequisites_tags'] ?? '' ) );
$prereq_tags = array_values( array_filter(
array_map( 'trim', explode( ',', $raw_tags ) )
) );
update_post_meta( $post_id, '_acc_prerequisites', [
'text' => $prereq_text,
'tags' => $prereq_tags,
] );
$raw_schedule = (array) ( $_POST['acc_schedule'] ?? [] );
$schedule = [];
$allowed_days = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri' ];
foreach ( $raw_schedule as $entry ) {
$day = sanitize_text_field( wp_unslash( $entry['day'] ?? 'Mon' ) );
$start = sanitize_text_field( wp_unslash( $entry['start_time'] ?? '' ) );
$end = sanitize_text_field( wp_unslash( $entry['end_time'] ?? '' ) );
$room = sanitize_text_field( wp_unslash( $entry['room'] ?? '' ) );
if ( in_array( $day, $allowed_days, true ) && ( $start !== '' || $room !== '' ) ) {
$schedule[] = [
'day' => $day,
'start_time' => $start,
'end_time' => $end,
'room' => $room,
];
}
}
update_post_meta( $post_id, '_acc_schedule', $schedule );
$raw_reading = (array) ( $_POST['acc_reading_list'] ?? [] );
$reading = [];
foreach ( $raw_reading as $entry ) {
$title = sanitize_text_field( wp_unslash( $entry['title'] ?? '' ) );
$authors = sanitize_text_field( wp_unslash( $entry['authors'] ?? '' ) );
$year = sanitize_text_field( wp_unslash( $entry['year'] ?? '' ) );
if ( $title !== '' ) {
$reading[] = [
'title' => $title,
'authors' => $authors ?: null,
'year' => $year ?: null,
];
}
}
update_post_meta( $post_id, '_acc_reading_list', $reading );
}
// -------------------------------------------------------------------------
// Admin list columns
// -------------------------------------------------------------------------
public function register_columns( array $columns ): array {
return [
'cb' => $columns['cb'],
'title' => __( 'Course Name', 'academiccrew' ),
'acc_code' => __( 'Code', 'academiccrew' ),
'acc_level' => __( 'Level', 'academiccrew' ),
'acc_featured' => __( 'Featured', 'academiccrew' ),
'acc_credits' => __( 'CFU', 'academiccrew' ),
'acc_period' => __( 'Period', 'academiccrew' ),
'acc_acad_year' => __( 'A.Y.', 'academiccrew' ),
'acc_language' => __( 'Lang.', 'academiccrew' ),
'acc_teachers' => __( 'Teachers', 'academiccrew' ),
'acc_page' => __( 'Profile Page', 'academiccrew' ),
'date' => $columns['date'],
];
}
public function render_column( string $column, int $post_id ): void {
switch ( $column ) {
case 'acc_level':
$level_labels = [
'bsc' => 'BSc',
'msc' => 'MSc',
'phd' => 'PhD',
'professional' => __( 'Professional', 'academiccrew' ),
'other' => __( 'Other', 'academiccrew' ),
];
$lv = get_post_meta( $post_id, '_acc_level', true );
echo esc_html( $lv ? ( $level_labels[ $lv ] ?? ucfirst( $lv ) ) : '—' );
break;
case 'acc_featured':
echo get_post_meta( $post_id, '_acc_featured', true ) ? '★' : '—';
break;
case 'acc_code':
$all_codes = (array) ( get_post_meta( $post_id, '_acc_course_codes', true ) ?: [] );
if ( empty( $all_codes ) ) {
$primary = get_post_meta( $post_id, '_acc_course_code', true );
if ( $primary ) {
$all_codes = [ $primary ];
}
}
if ( $all_codes ) {
$parts = array_map(
fn( $c ) => '' . esc_html( $c ) . '',
$all_codes
);
echo implode( ' ', $parts ); // phpcs:ignore WordPress.Security.EscapeOutput
}
break;
case 'acc_credits':
echo esc_html( get_post_meta( $post_id, '_acc_credits', true ) ?: '—' );
break;
case 'acc_period':
$p = get_post_meta( $post_id, '_acc_period', true );
echo $p ? esc_html( 'P' . $p ) : '—';
break;
case 'acc_acad_year':
$p_col = (int) ( get_post_meta( $post_id, '_acc_period', true ) ?: 1 );
$year_override = (bool) get_post_meta( $post_id, '_acc_year_override', true );
$col_archived = (bool) get_post_meta( $post_id, '_acc_archived', true );
$y = ( $year_override || $col_archived )
? (int) get_post_meta( $post_id, '_acc_academic_year', true )
: $this->calculator->calculate( $p_col );
echo esc_html( $this->calculator->format( $y ) );
if ( $col_archived ) {
echo ' ' . esc_html__( 'archived', 'academiccrew' ) . ' ';
} elseif ( $year_override ) {
echo ' ' . esc_html__( '(manual)', 'academiccrew' ) . ' ';
}
break;
case 'acc_language':
$lang = get_post_meta( $post_id, '_acc_language', true );
echo $lang ? esc_html( $lang ) : '—';
break;
case 'acc_teachers':
$instructors = (array) ( get_post_meta( $post_id, '_acc_instructors', true ) ?: [] );
$names = [];
foreach ( $instructors as $e ) {
if ( 'teacher' === ( $e['role'] ?? '' ) ) {
$name = (int) ( $e['member_id'] ?? 0 )
? get_the_title( (int) $e['member_id'] )
: (string) ( $e['name'] ?? '' );
if ( $name ) {
$names[] = $name;
}
}
}
echo esc_html( $names ? implode( ', ', $names ) : '—' );
break;
case 'acc_page':
$page_id = (int) get_post_meta( $post_id, '_academiccrew_linked_page_id', true );
if ( $page_id && get_post( $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;
}
}
public function sortable_columns( array $columns ): array {
$columns['acc_code'] = 'acc_code';
$columns['acc_acad_year'] = 'acc_acad_year';
$columns['acc_period'] = 'acc_period';
return $columns;
}
}