admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('aiaas_storage_v2'),
'deleteConfirm' => 'Delete ALL AAS files for this user? This is permanent.',
'workingText' => 'Working...',
'deletingText' => 'Deleting...',
));
wp_enqueue_script($handle);
}
}
if (!function_exists('aiaas_storage_v2_enabled')) {
function aiaas_storage_v2_enabled(): bool { return true; }
}
if (!function_exists('aiaas_storage_v2_human')) {
function aiaas_storage_v2_human($bytes): string {
$bytes = max(0, (int)$bytes);
if ($bytes < 1024) return $bytes . ' B';
$kb = $bytes / 1024;
if ($kb < 1024) return number_format($kb, 2) . ' KB';
$mb = $kb / 1024;
if ($mb < 1024) return number_format($mb, 2) . ' MB';
$gb = $mb / 1024;
return number_format($gb, 2) . ' GB';
}
}
/**
* Compute storage usage for a user.
* Counts:
* - AAS custom posts authored by user (post_content + selected meta)
* - Attachments authored by user (filesize)
*
* Returns array with totals and breakdown.
*/
if (!function_exists('aiaas_storage_v2_calc')) {
function aiaas_storage_v2_calc(int $user_id): array {
$uid = (int) $user_id;
$cache_key = 'aiaas_storage_' . $user_id;
$cached = get_transient($cache_key);
if (is_array($cached) && isset($cached['total_bytes'])) {
return $cached;
}
$post_types = apply_filters('aiaas_storage_v2_post_types', [
'aiaas_project',
'aiaas_series',
'aiaas_chapter',
'aiaas_component',
'aiaas_book_version',
'aiaas_template',
]);
$meta_keys = apply_filters('aiaas_storage_v2_meta_keys', [
'aiaas_chapter_notes',
'aiaas_snapshot',
'aiaas_revision_text',
'aiaas_import_raw',
'aiaas_outline_json',
]);
$total_text = 0;
$total_meta = 0;
$total_media = 0;
// Posts + postmeta (use WP APIs to avoid direct SQL warnings in Plugin Check)
if (!empty($post_types)) {
foreach ($post_types as $pt) {
$q = new WP_Query([
'post_type' => $pt,
'post_status' => ['publish','draft','pending','private','future'],
'author' => $user_id,
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
]);
if (!empty($q->posts)) {
foreach ($q->posts as $pid) {
$p = get_post((int)$pid);
if ($p && isset($p->post_content)) {
$total_text += strlen((string)$p->post_content);
}
if (!empty($meta_keys)) {
foreach ($meta_keys as $mk) {
$mv = get_post_meta((int)$pid, (string)$mk, true);
if (is_string($mv)) {
$total_meta += strlen($mv);
} elseif (is_array($mv)) {
// Best-effort: count serialized size
$total_meta += strlen(wp_json_encode($mv));
}
}
}
}
}
wp_reset_postdata();
}
}
// Media (attachments) that belong to this user.
// We cannot rely on post_author alone because some imports create attachments with author=0.
// We count attachments if ANY of the following is true:
// - attachment post_author is the user
// - attachment post_parent is a project/chapter owned by the user
// - attachment has AAS upload meta pointing to a user-owned project
$owned_parent_ids = [];
// Projects + chapters owned by user (preferred) with back-compat to post_author.
$owned_parent_ids = array_merge(
get_posts([
'post_type' => ['aiaas_project','aiaas_chapter'],
'post_status' => 'any',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => [
[
'key' => '_aiaas_owner_user_id',
'value' => (string) $user_id,
'compare' => '=',
],
],
]),
get_posts([
'post_type' => ['aiaas_project','aiaas_chapter'],
'post_status' => 'any',
'author' => $user_id,
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
])
);
$owned_parent_ids = array_values(array_unique(array_map('intval', array_filter($owned_parent_ids))));
$attachments_by_author = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'author' => $user_id,
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
]);
// Attachments related to this user.
// IMPORTANT: we do not rely on post_parent because many imported attachments have post_parent = 0.
$owned_project_ids = [];
foreach ((array) $owned_parent_ids as $pid) {
if (get_post_type((int) $pid) === 'aiaas_project') {
$owned_project_ids[] = (int) $pid;
}
}
$owned_project_ids = array_values(array_unique(array_map('intval', $owned_project_ids)));
$meta_or = [
// Preferred ownership marker (we backfill it below when possible).
[
'key' => '_aiaas_owner_user_id',
'value' => (string) $uid,
'compare' => '=',
],
// Typical AAS marker.
[
'key' => '_aiaas_uploaded_via_aas',
'compare' => 'EXISTS',
],
];
// Attachments linked to projects owned by the user (legacy / missing owner marker).
if (!empty($owned_project_ids)) {
$meta_or[] = [
'key' => '_aiaas_uploaded_project_id',
'value' => array_map('strval', $owned_project_ids),
'compare' => 'IN',
];
}
$meta_query = ['relation' => 'OR'];
foreach ($meta_or as $mq) { $meta_query[] = $mq; }
$attachments_related_args = [
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => $meta_query,
];
// IMPORTANT:
// Do NOT add post_parent__in to this query.
// Many AAS-imported attachments have post_parent = 0, and adding post_parent__in
// would accidentally filter out attachments that match the meta_query.
$attachments_related = get_posts($attachments_related_args);
// Separate query: attachments physically attached (post_parent) to owned content.
// Kept separate to avoid filtering out meta-matched attachments whose post_parent is 0.
$attachments_by_parent = [];
if (!empty($owned_parent_ids)) {
$attachments_by_parent = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
'post_parent__in' => $owned_parent_ids,
]);
}
// Fallback: Attachments referenced in the user's chapter/project content.
// This is critical for DOCX imports where WP attachments may have post_author != user
// and post_parent = 0, but are embedded via URLs.
$attachments_from_content = [];
$owned_content_ids = $owned_parent_ids;
if (!empty($owned_content_ids)) {
foreach ($owned_content_ids as $pid) {
$content = (string) get_post_field('post_content', (int)$pid);
if ($content === '') continue;
// 1) Fast ID extraction from common WP markup.
// Gutenberg / classic image markup often includes wp-image-{ID}.
$id_matches = [];
if (preg_match_all('~\\bwp-image-(\\d+)\\b~', $content, $id_matches)) {
foreach ((array)$id_matches[1] as $id) {
$id = (int) $id;
if ($id > 0) $attachments_from_content[] = $id;
}
}
// Classic [caption] shortcodes often include attachment_{ID}.
$id_matches = [];
if (preg_match_all('~\\battachment_(\\d+)\\b~', $content, $id_matches)) {
foreach ((array)$id_matches[1] as $id) {
$id = (int) $id;
if ($id > 0) $attachments_from_content[] = $id;
}
}
// Match absolute uploads URLs.
$matches = [];
if (preg_match_all('~(?:https?:)?//[^"\'\s>]+/wp-content/uploads/[^"\'\s>]+~i', $content, $matches)) {
foreach ((array)$matches[0] as $u) {
$u = html_entity_decode((string)$u, ENT_QUOTES);
// Strip querystrings/fragments; attachment_url_to_postid is strict.
$u = preg_replace('~[?#].*$~', '', $u);
$aid = (int) attachment_url_to_postid($u);
if ($aid) $attachments_from_content[] = $aid;
}
}
// Match relative uploads URLs.
$matches = [];
if (preg_match_all('~(/wp-content/uploads/[^"\'\s>]+)~i', $content, $matches)) {
foreach ((array)$matches[1] as $rel) {
$rel = html_entity_decode((string)$rel, ENT_QUOTES);
$rel = preg_replace('~[?#].*$~', '', $rel);
$u = rtrim(site_url('/'), '/') . $rel;
$aid = (int) attachment_url_to_postid($u);
if ($aid) $attachments_from_content[] = $aid;
}
}
}
}
$attachments = array_values(array_unique(array_map('intval', array_filter(array_merge(
(array)$attachments_by_author,
(array)$attachments_related,
(array)$attachments_by_parent,
(array)$attachments_from_content
)))));
// Backfill ownership for any attachment we can confidently attribute.
// If it has a project id that belongs to the user, stamp _aiaas_owner_user_id.
if (!empty($owned_project_ids) && !empty($attachments)) {
foreach ($attachments as $aid) {
$aid = (int) $aid;
if ($aid <= 0) continue;
$existing_owner = (int) get_post_meta($aid, '_aiaas_owner_user_id', true);
if ($existing_owner > 0) continue;
$pid = (int) get_post_meta($aid, '_aiaas_uploaded_project_id', true);
if ($pid > 0 && in_array($pid, $owned_project_ids, true)) {
update_post_meta($aid, '_aiaas_owner_user_id', (string) $uid);
}
}
}
if (!empty($attachments)) {
$missing = 0;
$missing_samples = [];
foreach ($attachments as $aid) {
$aid = (int) $aid;
if ($aid <= 0) { continue; }
$path = get_attached_file($aid);
if ($path && file_exists($path)) {
$total_media += (int) filesize($path);
} else {
$missing++;
if (count($missing_samples) < 10) {
$missing_samples[] = [
'id' => $aid,
'url' => wp_get_attachment_url($aid),
'path' => $path,
];
}
}
}
}
$total_bytes = (int) $total_text + (int) $total_meta + (int) $total_media;
$out = [
'user_id' => (int) $user_id,
'total_bytes' => $total_bytes,
'text_bytes' => (int) $total_text,
'meta_bytes' => (int) $total_meta,
'media_bytes' => (int) $total_media,
'updated_at' => time(),
];
// Cache briefly to avoid repeated heavy scans
set_transient($cache_key, $out, 5 * MINUTE_IN_SECONDS);
return $out;
}
}
if (!function_exists('aiaas_storage_v2_save')) {
function aiaas_storage_v2_save(int $user_id, array $data): void {
update_user_meta($user_id, 'aiaas_storage_v2', $data);
update_user_meta($user_id, 'aiaas_storage_total_bytes', (int)($data['total_bytes'] ?? 0));
update_user_meta($user_id, 'aiaas_storage_total_human', (string)($data['total_human'] ?? '0 B'));
delete_transient('aiaas_storage_' . $user_id);
}
}
if (!function_exists('aiaas_storage_v2_delete_all_user_files')) {
function aiaas_storage_v2_delete_all_user_files(int $user_id): array {
// Delete ALL AAS posts + media for this user.
// IMPORTANT: we cannot rely on post_author alone because some imports create posts/attachments with author=0.
// We treat a post/media item as "owned" by this user if:
// - post_author is the user OR
// - post has _aiaas_owner_user_id = user OR
// - attachment is linked to a user-owned project/chapter via post_parent OR AAS meta markers OR is referenced in owned content
$post_types = apply_filters('aiaas_storage_v2_post_types', [
'aiaas_project','aiaas_series','aiaas_chapter','aiaas_component','aiaas_book_version','aiaas_template'
]);
$deleted_posts = 0;
$deleted_media = 0;
// 1) Delete AAS posts owned by user (meta owner marker).
$posts_meta = get_posts([
'post_type' => $post_types,
'post_status' => 'any',
'fields' => 'ids',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => [
[
'key' => '_aiaas_owner_user_id',
'value' => (string) $user_id,
'compare' => '=',
],
],
]);
// 2) Delete AAS posts authored by user (back-compat).
$posts_author = get_posts([
'post_type' => $post_types,
'post_status' => 'any',
'author' => $user_id,
'fields' => 'ids',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
$posts = array_values(array_unique(array_map('intval', array_filter(array_merge((array)$posts_meta, (array)$posts_author)))));
foreach ($posts as $pid) {
wp_delete_post((int)$pid, true);
$deleted_posts++;
}
// 3) Delete attachments (media) that belong to this user.
// This mirrors the media ownership logic used in storage calculations.
$owned_parent_ids = array_values(array_unique(array_map('intval', array_filter(array_merge(
get_posts([
'post_type' => ['aiaas_project','aiaas_chapter'],
'post_status' => 'any',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => [
[
'key' => '_aiaas_owner_user_id',
'value' => (string) $user_id,
'compare' => '=',
],
],
]),
get_posts([
'post_type' => ['aiaas_project','aiaas_chapter'],
'post_status' => 'any',
'author' => $user_id,
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
])
)))));
$owned_project_ids = [];
foreach ($owned_parent_ids as $pid) {
if (get_post_type((int)$pid) === 'aiaas_project') {
$owned_project_ids[] = (int)$pid;
}
}
$owned_project_ids = array_values(array_unique(array_map('intval', $owned_project_ids)));
$attachments_by_author = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'author' => $user_id,
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
]);
$meta_or = [
[
'key' => '_aiaas_owner_user_id',
'value' => (string) $user_id,
'compare' => '=',
],
[
'key' => '_aiaas_uploaded_via_aas',
'compare' => 'EXISTS',
],
];
if (!empty($owned_project_ids)) {
$meta_or[] = [
'key' => '_aiaas_uploaded_project_id',
'value' => array_map('strval', $owned_project_ids),
'compare' => 'IN',
];
}
$meta_query = ['relation' => 'OR'];
foreach ($meta_or as $mq) { $meta_query[] = $mq; }
$attachments_related = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => $meta_query,
]);
$attachments_by_parent = [];
if (!empty($owned_parent_ids)) {
$attachments_by_parent = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => -1,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
'post_parent__in' => $owned_parent_ids,
]);
}
// Attachments referenced in owned content (critical fallback for DOCX/PDF imports).
$attachments_from_content = [];
if (!empty($owned_parent_ids)) {
foreach ($owned_parent_ids as $pid) {
$content = (string) get_post_field('post_content', (int)$pid);
if ($content === '') continue;
$id_matches = [];
if (preg_match_all('~\\bwp-image-(\\d+)\\b~', $content, $id_matches)) {
foreach ((array)$id_matches[1] as $id) {
$id = (int) $id;
if ($id > 0) $attachments_from_content[] = $id;
}
}
$id_matches = [];
if (preg_match_all('~\\battachment_(\\d+)\\b~', $content, $id_matches)) {
foreach ((array)$id_matches[1] as $id) {
$id = (int) $id;
if ($id > 0) $attachments_from_content[] = $id;
}
}
$matches = [];
if (preg_match_all('~(?:https?:)?//[^"\'\\s>]+/wp-content/uploads/[^"\'\\s>]+~i', $content, $matches)) {
foreach ((array)$matches[0] as $u) {
$u = html_entity_decode((string)$u, ENT_QUOTES);
$u = preg_replace('~[?#].*$~', '', $u);
$aid = (int) attachment_url_to_postid($u);
if ($aid) $attachments_from_content[] = $aid;
}
}
$matches = [];
if (preg_match_all('~(/wp-content/uploads/[^"\'\\s>]+)~i', $content, $matches)) {
foreach ((array)$matches[1] as $rel) {
$rel = html_entity_decode((string)$rel, ENT_QUOTES);
$rel = preg_replace('~[?#].*$~', '', $rel);
$u = rtrim(site_url('/'), '/') . $rel;
$aid = (int) attachment_url_to_postid($u);
if ($aid) $attachments_from_content[] = $aid;
}
}
}
}
$attachments = array_values(array_unique(array_map('intval', array_filter(array_merge(
(array)$attachments_by_author,
(array)$attachments_related,
(array)$attachments_by_parent,
(array)$attachments_from_content
)))));
foreach ($attachments as $aid) {
wp_delete_attachment((int)$aid, true);
$deleted_media++;
}
// Clear stored totals
delete_user_meta($user_id, 'aiaas_storage_v2');
update_user_meta($user_id, 'aiaas_storage_total_bytes', 0);
update_user_meta($user_id, 'aiaas_storage_total_human', '0 B');
return [
'user_id' => $user_id,
'deleted_posts' => $deleted_posts,
'deleted_media' => $deleted_media,
'done' => true,
];
}
}
/** Admin menu page */
add_action('admin_menu', function () {
if (!aiaas_storage_v2_enabled()) return;
add_submenu_page(
'aiaas-settings',
'Storage Tools',
'Storage Tools',
'manage_options',
'aiaas-storage-tools',
'aiaas_storage_v2_render_admin_page'
);
});
if (!function_exists('aiaas_storage_v2_render_admin_page')) {
function aiaas_storage_v2_render_admin_page() {
if (!current_user_can('manage_options')) {
wp_die('Permission denied.');
}
$users = get_users(['fields' => ['ID','display_name','user_login'], 'number' => 500]);
?>
Select a user, then calculate storage or delete all their AAS files.