GitGram — functions.php — GitGram
TaskGram / main / v1.00 / includes / functions.php41,375 B↓ Raw
<?php
declare(strict_types=1);

require_once __DIR__ . '/config.php';

/* ─── Storage bootstrap ───────────────────────────────────────────── */

function init_storage(): void {
    foreach ([DATA_DIR, USERS_DIR, LISTS_DIR] as $dir) {
        if (!is_dir($dir)) {
            mkdir($dir, 0750, true);
        }
    }
    if (!file_exists(USERS_INDEX)) {
        save_json(USERS_INDEX, []);
    }
    init_uploads();
    migrate_slugs();
}

function migrate_slugs(): void {
    // Backfill slug field on any lists that predate slug support
    foreach (glob(LISTS_DIR . '/*', GLOB_ONLYDIR) as $udir) {
        $username = basename($udir);
        foreach (glob($udir . '/*.json') as $file) {
            $list = read_json($file);
            if (!$list || isset($list['slug'])) continue;
            $list['slug'] = make_unique_slug($username, $list['title'] ?? 'list', $list['id'] ?? null);
            save_json($file, $list);
        }
    }
}

/* ─── Atomic JSON helpers ─────────────────────────────────────────── */

function read_json(string $path): array {
    if (!file_exists($path)) return [];
    $raw = file_get_contents($path);
    return ($raw !== false) ? (json_decode($raw, true) ?? []) : [];
}

function save_json(string $path, array $data): void {
    $tmp = $path . '.tmp.' . getmypid();
    file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
    rename($tmp, $path);
}

/* ─── Input validation ────────────────────────────────────────────── */

function validate_username(string $u): bool {
    return (bool) preg_match('/^[a-z][a-z0-9_-]{' . (MIN_USERNAME_LEN - 1) . ',' . (MAX_USERNAME_LEN - 1) . '}$/', $u);
}

function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

/* ─── CSRF ────────────────────────────────────────────────────────── */

function csrf_token(): string {
    session_start_safe();
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function verify_csrf(): bool {
    session_start_safe();
    $token = $_POST['csrf_token'] ?? '';
    return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}

function csrf_field(): string {
    return '<input type="hidden" name="csrf_token" value="' . h(csrf_token()) . '">';
}

/* ─── Flash messages ──────────────────────────────────────────────── */

function flash(string $type, string $message): void {
    session_start_safe();
    $_SESSION['flash'] = ['type' => $type, 'message' => $message];
}

function get_flash(): ?array {
    session_start_safe();
    $f = $_SESSION['flash'] ?? null;
    unset($_SESSION['flash']);
    return $f;
}

/* ─── Session ─────────────────────────────────────────────────────── */

function session_start_safe(): void {
    if (session_status() === PHP_SESSION_NONE) {
        session_name(SESSION_NAME);
        session_set_cookie_params([
            'lifetime' => 0,
            'path'     => '/',
            'secure'   => false,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
        session_start();
    }
}

function get_session_user(): ?array {
    session_start_safe();
    return $_SESSION['user'] ?? null;
}

function require_login(): array {
    $u = get_session_user();
    if (!$u) {
        header('Location: index.php');
        exit;
    }
    return $u;
}

function login_user(array $user): void {
    session_start_safe();
    session_regenerate_id(true);
    $_SESSION['user'] = $user;
}

function logout_user(): void {
    session_start_safe();
    $_SESSION = [];
    session_destroy();
}

/* ─── URL / upload helpers ────────────────────────────────────────── */

function site_base_url(): string {
    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
    return rtrim($scheme . '://' . $host, '/');
}

function init_uploads(): void {
    $avatar_dir = UPLOADS_DIR . '/avatars';
    foreach ([UPLOADS_DIR, $avatar_dir] as $d) {
        if (!is_dir($d)) mkdir($d, 0755, true);
    }
}

/**
 * Validates an uploaded image file, moves it to $dest_dir/$base_name.{ext},
 * removes any previous file with the same base name, returns the filename or false.
 */
function save_uploaded_image(array $file, string $dest_dir, string $base_name): string|false {
    if ($file['error'] !== UPLOAD_ERR_OK)    return false;
    if ($file['size']  > 2 * 1024 * 1024)   return false;

    $info = @getimagesize($file['tmp_name']);
    if (!$info) return false;

    $ext_map = [
        IMAGETYPE_JPEG => 'jpg',
        IMAGETYPE_PNG  => 'png',
        IMAGETYPE_GIF  => 'gif',
        IMAGETYPE_WEBP => 'webp',
    ];
    $ext = $ext_map[$info[2] ?? 0] ?? null;
    if (!$ext) return false;

    if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true);

    // Remove any existing file with the same base regardless of extension
    foreach (glob($dest_dir . '/' . $base_name . '.*') ?: [] as $old) {
        unlink($old);
    }

    $dest = $dest_dir . '/' . $base_name . '.' . $ext;
    if (!move_uploaded_file($file['tmp_name'], $dest)) return false;

    return $base_name . '.' . $ext;
}

function delete_upload(string $dest_dir, string $base_name): void {
    foreach (glob($dest_dir . '/' . $base_name . '.*') ?: [] as $f) {
        unlink($f);
    }
}

function get_og_logo_url(): string {
    $cfg  = get_site_config();
    $logo = $cfg['og_logo'] ?? '';
    if ($logo && file_exists(UPLOADS_DIR . '/' . $logo)) {
        return site_base_url() . '/uploads/' . rawurlencode($logo);
    }
    return '';
}

function get_user_avatar_url(string $username): string {
    $username = strtolower($username);
    $profile  = get_user_profile($username);
    $avatar   = $profile['avatar'] ?? '';
    if ($avatar && file_exists(UPLOADS_DIR . '/avatars/' . $avatar)) {
        return site_base_url() . '/uploads/avatars/' . rawurlencode($avatar);
    }
    return '';
}

/* ─── Site configuration ──────────────────────────────────────────── */

define('SITE_CONFIG_FILE', DATA_DIR . '/site_config.json');
define('DEFAULT_FOOTER_TEXT', 'TaskGram by Amfile.org of Page Telegram Volunteer Services Copyright {year}');

function get_site_config(): array {
    $cfg = read_json(SITE_CONFIG_FILE);
    return array_merge(['footer_text' => DEFAULT_FOOTER_TEXT], $cfg);
}

function save_site_config(array $cfg): void {
    save_json(SITE_CONFIG_FILE, $cfg);
}

function get_footer_text(): string {
    $cfg  = get_site_config();
    $text = $cfg['footer_text'] ?? DEFAULT_FOOTER_TEXT;
    return str_replace('{year}', date('Y'), $text);
}

function is_admin(array $user): bool {
    return !empty($user['admin']);
}

/* ─── User management ─────────────────────────────────────────────── */

function user_exists(string $username): bool {
    $idx = read_json(USERS_INDEX);
    return isset($idx[strtolower($username)]);
}

function create_user(string $username, string $password, string $display_name, string $email): string|bool {
    $username = strtolower(trim($username));
    if (!validate_username($username))      return 'Invalid username (3-32 chars, letters/numbers/dash/underscore, start with letter)';
    if (strlen($password) < MIN_PASSWORD_LEN) return 'Password must be at least ' . MIN_PASSWORD_LEN . ' characters';
    if (user_exists($username))             return 'Username already taken';
    if (trim($display_name) === '')         $display_name = $username;
    if (!filter_var($email, FILTER_VALIDATE_EMAIL) && $email !== '') return 'Invalid email address';

    $user_dir = USERS_DIR . '/' . $username;
    if (!is_dir($user_dir)) mkdir($user_dir, 0750, true);

    $lists_dir = LISTS_DIR . '/' . $username;
    if (!is_dir($lists_dir)) mkdir($lists_dir, 0750, true);

    $idx     = read_json(USERS_INDEX);
    $is_first = empty($idx); // First user becomes admin

    $profile = [
        'username'      => $username,
        'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]),
        'display_name'  => trim($display_name),
        'email'         => $email,
        'created'       => date('Y-m-d H:i:s'),
        'admin'         => $is_first,
    ];
    save_json($user_dir . '/profile.json', $profile);

    $idx[$username] = ['display_name' => $profile['display_name'], 'created' => $profile['created']];
    save_json(USERS_INDEX, $idx);

    return true;
}

function authenticate(string $username, string $password): array|string {
    $username = strtolower(trim($username));
    $file     = USERS_DIR . '/' . $username . '/profile.json';
    if (!file_exists($file)) return 'Invalid username or password';

    $profile = read_json($file);
    if (!password_verify($password, $profile['password_hash'] ?? '')) return 'Invalid username or password';

    unset($profile['password_hash']);
    return $profile;
}

function get_user_profile(string $username): ?array {
    $file = USERS_DIR . '/' . strtolower($username) . '/profile.json';
    if (!file_exists($file)) return null;
    $p = read_json($file);
    unset($p['password_hash']);
    return $p;
}

function update_password(string $username, string $old_password, string $new_password): string|bool {
    $username = strtolower($username);
    $file     = USERS_DIR . '/' . $username . '/profile.json';
    $profile  = read_json($file);

    if (!password_verify($old_password, $profile['password_hash'] ?? '')) return 'Current password incorrect';
    if (strlen($new_password) < MIN_PASSWORD_LEN) return 'New password must be at least ' . MIN_PASSWORD_LEN . ' characters';

    $profile['password_hash'] = password_hash($new_password, PASSWORD_BCRYPT, ['cost' => 12]);
    save_json($file, $profile);
    return true;
}

function save_user_avatar(string $username, array $file): string|false {
    init_uploads();
    $filename = save_uploaded_image($file, UPLOADS_DIR . '/avatars', $username);
    if (!$filename) return false;

    $profile_file = USERS_DIR . '/' . $username . '/profile.json';
    $profile = read_json($profile_file);
    $profile['avatar'] = $filename;
    save_json($profile_file, $profile);
    return $filename;
}

function remove_user_avatar(string $username): void {
    delete_upload(UPLOADS_DIR . '/avatars', $username);
    $profile_file = USERS_DIR . '/' . $username . '/profile.json';
    $profile = read_json($profile_file);
    unset($profile['avatar']);
    save_json($profile_file, $profile);
}

function update_display_name(string $username, string $display_name): void {
    $username = strtolower($username);
    $file     = USERS_DIR . '/' . $username . '/profile.json';
    $profile  = read_json($file);
    $profile['display_name'] = trim($display_name) ?: $username;
    save_json($file, $profile);

    $idx = read_json(USERS_INDEX);
    if (isset($idx[$username])) {
        $idx[$username]['display_name'] = $profile['display_name'];
        save_json(USERS_INDEX, $idx);
    }
}

/* ─── Slug helpers ────────────────────────────────────────────────── */

function slugify(string $title): string {
    $s = mb_strtolower($title, 'UTF-8');
    // Transliterate common accented chars
    $s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s;
    $s = preg_replace('/[^a-z0-9]+/', '-', $s);
    $s = trim($s, '-');
    $s = substr($s, 0, 64);
    return $s ?: 'list';
}

function make_unique_slug(string $username, string $title, ?string $exclude_id = null): string {
    $base    = slugify($title);
    $dir     = LISTS_DIR . '/' . strtolower($username);
    $slugs   = [];

    if (is_dir($dir)) {
        foreach (glob($dir . '/*.json') as $file) {
            $data = read_json($file);
            if (!$data) continue;
            if ($exclude_id && ($data['id'] ?? '') === $exclude_id) continue;
            if (isset($data['slug'])) $slugs[] = $data['slug'];
        }
    }

    $candidate = $base;
    $counter   = 2;
    while (in_array($candidate, $slugs, true)) {
        $candidate = $base . '-' . $counter++;
    }
    return $candidate;
}

/* ─── List management ─────────────────────────────────────────────── */

function get_lists_for_user(string $username): array {
    $dir = LISTS_DIR . '/' . strtolower($username);
    if (!is_dir($dir)) return [];

    $lists = [];
    foreach (glob($dir . '/*.json') as $file) {
        $list = read_json($file);
        if ($list) {
            $list['active_count']    = count($list['active']    ?? []);
            $list['completed_count'] = count($list['completed'] ?? []);
            unset($list['active'], $list['completed']);
            $lists[] = $list;
        }
    }
    usort($lists, fn($a, $b) => strcmp($b['updated'] ?? '', $a['updated'] ?? ''));
    return $lists;
}

function get_public_lists(int $limit = 100): array {
    $all = [];
    foreach (glob(LISTS_DIR . '/*', GLOB_ONLYDIR) as $udir) {
        $username = basename($udir);
        foreach (glob($udir . '/*.json') as $file) {
            $list = read_json($file);
            if ($list && ($list['public'] ?? false)) {
                $list['active_count']    = count($list['active']    ?? []);
                $list['completed_count'] = count($list['completed'] ?? []);
                unset($list['active'], $list['completed']);
                $all[] = $list;
            }
        }
    }
    usort($all, fn($a, $b) => strcmp($b['updated'] ?? '', $a['updated'] ?? ''));
    return array_slice($all, 0, $limit);
}

function get_list(string $username, string $list_id): ?array {
    $id   = preg_replace('/[^a-f0-9]/', '', $list_id);
    $file = LISTS_DIR . '/' . strtolower($username) . '/' . $id . '.json';
    if (!file_exists($file)) return null;
    return read_json($file);
}

function save_list(array $list): void {
    $dir = LISTS_DIR . '/' . $list['owner'];
    if (!is_dir($dir)) mkdir($dir, 0750, true);
    save_json($dir . '/' . $list['id'] . '.json', $list);
}

function create_list(string $username, string $title, string $description, bool $public): array {
    $title = trim($title);
    $list  = [
        'id'          => bin2hex(random_bytes(8)),
        'slug'        => make_unique_slug($username, $title),
        'title'       => $title,
        'description' => trim($description),
        'owner'       => strtolower($username),
        'public'      => $public,
        'created'     => date('Y-m-d H:i:s'),
        'updated'     => date('Y-m-d H:i:s'),
        'active'      => [],
        'completed'   => [],
    ];
    save_list($list);
    return $list;
}

function update_list_meta(string $username, string $list_id, string $title, string $description, bool $public): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;
    $title = trim($title);
    // Regenerate slug if title changed
    if ($title !== $list['title']) {
        $list['slug'] = make_unique_slug($username, $title, $list_id);
    }
    $list['title']       = $title;
    $list['description'] = trim($description);
    $list['public']      = $public;
    $list['updated']     = date('Y-m-d H:i:s');
    save_list($list);
    return true;
}

function get_list_by_slug(string $username, string $slug): ?array {
    $dir = LISTS_DIR . '/' . strtolower($username);
    if (!is_dir($dir)) return null;
    foreach (glob($dir . '/*.json') as $file) {
        $data = read_json($file);
        if ($data && ($data['slug'] ?? '') === $slug) return $data;
    }
    return null;
}

function delete_list(string $username, string $list_id): bool {
    $id   = preg_replace('/[^a-f0-9]/', '', $list_id);
    $file = LISTS_DIR . '/' . strtolower($username) . '/' . $id . '.json';
    if (!file_exists($file)) return false;
    return unlink($file);
}

function can_view_list(array $list, ?array $current_user): bool {
    if ($list['public']) return true;
    if ($current_user && $current_user['username'] === $list['owner']) return true;
    return has_list_access($list['id'] ?? ''); // token or password grant in session
}

function generate_share_token(string $username, string $list_id): string {
    $list = get_list($username, $list_id);
    if (!$list) return '';
    $token = bin2hex(random_bytes(16));
    $list['share_token'] = $token;
    save_list($list);
    return $token;
}

function revoke_share_token(string $username, string $list_id): void {
    $list = get_list($username, $list_id);
    if (!$list) return;
    unset($list['share_token']);
    save_list($list);
}

function check_share_token(array $list, string $token): bool {
    if ($token === '') return false;
    return isset($list['share_token']) && hash_equals($list['share_token'], $token);
}

function can_edit_list(array $list, ?array $current_user): bool {
    if (!$current_user) return false;
    return $current_user['username'] === $list['owner'];
}

/* ─── Task management ─────────────────────────────────────────────── */

function parse_due_input(string $input): string {
    $input = trim($input);
    if ($input === '') return '';
    // datetime-local sends "Y-m-d\TH:i" — normalise to "Y-m-d H:i:s"
    $ts = strtotime(str_replace('T', ' ', $input));
    return ($ts !== false && $ts > 0) ? date('Y-m-d H:i:s', $ts) : '';
}

function due_status(string $due): array {
    if ($due === '') return ['class' => '', 'label' => ''];
    $ts  = strtotime($due);
    if ($ts === false) return ['class' => '', 'label' => ''];
    $now  = time();
    $diff = $ts - $now;
    if ($diff < 0) {
        return ['class' => 'due-overdue', 'label' => 'Overdue'];
    }
    if ($diff < 86400) { // within 24 h
        return ['class' => 'due-today',   'label' => 'Due today'];
    }
    if ($diff < 172800) { // within 48 h
        return ['class' => 'due-soon',    'label' => 'Due tomorrow'];
    }
    return ['class' => 'due-future', 'label' => 'Due ' . date('d M Y', $ts)];
}

function add_task(string $username, string $list_id, string $text, string $priority = 'normal', string $due = ''): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;

    $task = [
        'id'       => bin2hex(random_bytes(6)),
        'text'     => trim($text),
        'priority' => in_array($priority, ['high','normal','low']) ? $priority : 'normal',
        'created'  => date('Y-m-d H:i:s'),
    ];
    if ($due !== '') $task['due'] = $due;
    $list['active'][] = $task;
    $list['updated'] = date('Y-m-d H:i:s');
    save_list($list);
    return true;
}

function edit_task(string $username, string $list_id, string $task_id, string $text, string $priority, ?int $order = null, string $due = '', bool $clear_due = false): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;

    foreach ($list['active'] as &$task) {
        if ($task['id'] === $task_id) {
            $task['text']     = trim($text);
            $task['priority'] = in_array($priority, ['high','normal','low']) ? $priority : 'normal';
            if ($order !== null) {
                $task['order'] = max(1, $order);
            } elseif (isset($task['order'])) {
                unset($task['order']); // clearing order field
            }
            if ($clear_due) {
                unset($task['due']);
            } elseif ($due !== '') {
                $task['due'] = $due;
            }
            $list['updated']  = date('Y-m-d H:i:s');
            save_list($list);
            return true;
        }
    }
    return false;
}

function complete_task(string $username, string $list_id, string $task_id): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;

    foreach ($list['active'] as $i => $task) {
        if ($task['id'] === $task_id) {
            $task['completed'] = date('Y-m-d H:i:s');
            array_unshift($list['completed'], $task);
            array_splice($list['active'], $i, 1);
            $list['updated'] = date('Y-m-d H:i:s');
            save_list($list);
            return true;
        }
    }
    return false;
}

function uncomplete_task(string $username, string $list_id, string $task_id): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;

    foreach ($list['completed'] as $i => $task) {
        if ($task['id'] === $task_id) {
            unset($task['completed']);
            $list['active'][] = $task;
            array_splice($list['completed'], $i, 1);
            $list['updated'] = date('Y-m-d H:i:s');
            save_list($list);
            return true;
        }
    }
    return false;
}

function delete_task(string $username, string $list_id, string $task_id, string $pool = 'active'): bool {
    $pool = $pool === 'completed' ? 'completed' : 'active';
    $list = get_list($username, $list_id);
    if (!$list) return false;

    foreach ($list[$pool] as $i => $task) {
        if ($task['id'] === $task_id) {
            array_splice($list[$pool], $i, 1);
            $list['updated'] = date('Y-m-d H:i:s');
            save_list($list);
            return true;
        }
    }
    return false;
}

function move_task_to_list(string $username, string $from_list_id, string $task_id, string $to_list_id): bool {
    if ($from_list_id === $to_list_id) return false;
    $from = get_list($username, $from_list_id);
    $to   = get_list($username, $to_list_id);
    if (!$from || !$to) return false;

    foreach ($from['active'] as $i => $task) {
        if ($task['id'] === $task_id) {
            $task['id']      = bin2hex(random_bytes(6)); // fresh ID in new list
            $task['created'] = $task['created'] ?? date('Y-m-d H:i:s');
            $to['active'][]  = $task;
            array_splice($from['active'], $i, 1);
            $from['updated'] = $to['updated'] = date('Y-m-d H:i:s');
            save_list($from);
            save_list($to);
            return true;
        }
    }
    return false;
}

function move_task(string $username, string $list_id, string $task_id, string $direction): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;

    foreach ($list['active'] as $i => $task) {
        if ($task['id'] === $task_id) {
            $j = $direction === 'up' ? $i - 1 : $i + 1;
            if ($j < 0 || $j >= count($list['active'])) return false;
            [$list['active'][$i], $list['active'][$j]] = [$list['active'][$j], $list['active'][$i]];
            save_list($list);
            return true;
        }
    }
    return false;
}

function clear_completed(string $username, string $list_id): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;
    $list['completed'] = [];
    $list['updated']   = date('Y-m-d H:i:s');
    save_list($list);
    return true;
}

/* ─── Password-protected public lists ────────────────────────────── */

define('RATE_LIMIT_DIR', DATA_DIR . '/rate_limits');
define('RATE_LIMIT_MAX',    3);
define('RATE_LIMIT_WINDOW', 900); // 15 minutes in seconds

function init_rate_limit_storage(): void {
    if (!is_dir(RATE_LIMIT_DIR)) mkdir(RATE_LIMIT_DIR, 0750, true);
}

function rate_limit_key(string $list_id): string {
    $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    return RATE_LIMIT_DIR . '/' . hash('sha256', $ip . '_' . $list_id) . '.json';
}

function check_rate_limit(string $list_id): bool {
    // Returns true if request is ALLOWED, false if BLOCKED
    init_rate_limit_storage();
    $file = rate_limit_key($list_id);
    $data = read_json($file);
    $now  = time();

    if (empty($data['window_start']) || ($now - $data['window_start']) >= RATE_LIMIT_WINDOW) {
        // Window expired or new entry — allow
        return true;
    }
    return ($data['attempts'] ?? 0) < RATE_LIMIT_MAX;
}

function record_failed_attempt(string $list_id): void {
    init_rate_limit_storage();
    $file = rate_limit_key($list_id);
    $data = read_json($file);
    $now  = time();

    if (empty($data['window_start']) || ($now - $data['window_start']) >= RATE_LIMIT_WINDOW) {
        $data = ['window_start' => $now, 'attempts' => 1];
    } else {
        $data['attempts'] = ($data['attempts'] ?? 0) + 1;
    }
    save_json($file, $data);
}

function reset_rate_limit(string $list_id): void {
    $file = rate_limit_key($list_id);
    if (file_exists($file)) unlink($file);
}

function get_rate_limit_wait(string $list_id): int {
    // Returns seconds remaining in lockout (0 if not locked)
    $file = rate_limit_key($list_id);
    $data = read_json($file);
    if (empty($data['window_start'])) return 0;
    $elapsed   = time() - $data['window_start'];
    $remaining = RATE_LIMIT_WINDOW - $elapsed;
    if ($remaining <= 0 || ($data['attempts'] ?? 0) < RATE_LIMIT_MAX) return 0;
    return (int) $remaining;
}

function generate_captcha(): array {
    // Returns ['question' => '...', 'answer' => N]
    $a  = random_int(1, 12);
    $b  = random_int(1, 12);
    $op = ['+', '-', '*'][random_int(0, 2)];
    $answer = match($op) {
        '+' => $a + $b,
        '-' => $a - $b,
        '*' => $a * $b,
    };
    return ['question' => "$a $op $b", 'answer' => $answer];
}

function set_captcha(): string {
    session_start_safe();
    $cap = generate_captcha();
    $_SESSION['captcha_answer'] = $cap['answer'];
    return $cap['question'];
}

function verify_captcha(string $user_answer): bool {
    session_start_safe();
    $expected = $_SESSION['captcha_answer'] ?? null;
    if ($expected === null) return false;
    unset($_SESSION['captcha_answer']);
    return (int) trim($user_answer) === (int) $expected;
}

function set_list_password(string $username, string $list_id, string $password): bool {
    $list = get_list($username, $list_id);
    if (!$list) return false;
    if ($password === '') {
        $list['password_hash'] = null;
    } else {
        $list['password_hash'] = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
    }
    save_list($list);
    return true;
}

function verify_list_password(array $list, string $password): bool {
    if (empty($list['password_hash'])) return true; // no password set
    return password_verify($password, $list['password_hash']);
}

function grant_list_access(string $list_id): void {
    session_start_safe();
    $_SESSION['list_access_' . $list_id] = true;
}

function has_list_access(string $list_id): bool {
    session_start_safe();
    return !empty($_SESSION['list_access_' . $list_id]);
}

/* ─── Hit counting ────────────────────────────────────────────────── */

function visitor_ip(): string {
    // Respect common reverse-proxy headers; take only the first (client) address.
    $raw = $_SERVER['HTTP_X_FORWARDED_FOR']
        ?? $_SERVER['HTTP_X_REAL_IP']
        ?? $_SERVER['REMOTE_ADDR']
        ?? '0.0.0.0';
    $ip = trim(explode(',', $raw)[0]);
    return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
}

function is_likely_bot(): bool {
    $ua = strtolower($_SERVER['HTTP_USER_AGENT'] ?? '');
    if ($ua === '') return true;
    foreach ([
        'bot','crawler','spider','slurp','feed','rss',
        'curl','wget','python','ruby','perl','java','go-http',
        'scrapy','httrack','archiver','validator','preview',
        'facebookexternalhit','twitterbot','linkedinbot',
        'whatsapp','telegram','discord','slack','skype',
        'pinterest','applebot','googlebot','bingbot','yandex',
        'baidu','duckduck','semrush','ahrefs','mj12',
    ] as $sig) {
        if (str_contains($ua, $sig)) return true;
    }
    return false;
}

function record_list_hit(string $list_id): void {
    if (is_likely_bot()) return;

    if (!is_dir(HITS_DIR)) mkdir(HITS_DIR, 0750, true);

    $file = HITS_DIR . '/' . $list_id . '.json';
    $data = read_json($file);

    $data['total'] = ($data['total'] ?? 0) + 1;

    // Hash IP+list_id so the same visitor has a different token per list
    $hash   = hash('sha256', visitor_ip() . ':' . $list_id);
    $unique = $data['unique'] ?? [];

    if (!in_array($hash, $unique, true)) {
        $unique[] = $hash;
        // Hard cap: keeps file size bounded
        if (count($unique) > 100_000) array_shift($unique);
        $data['unique'] = $unique;
    }

    save_json($file, $data);
}

function get_list_hits(string $list_id): array {
    $file = HITS_DIR . '/' . $list_id . '.json';
    $data = read_json($file);
    return [
        'total'  => (int) ($data['total'] ?? 0),
        'unique' => count($data['unique'] ?? []),
    ];
}

/* ─── Sort ────────────────────────────────────────────────────────── */

function sort_tasks(array $tasks, string $sort, string $pool = 'active'): array {
    switch ($sort) {
        case 'created_asc':
            usort($tasks, fn($a, $b) => strcmp($a['created'] ?? '', $b['created'] ?? ''));
            break;
        case 'created_desc':
            usort($tasks, fn($a, $b) => strcmp($b['created'] ?? '', $a['created'] ?? ''));
            break;
        case 'priority':
            $w = ['high' => 0, 'normal' => 1, 'low' => 2];
            usort($tasks, fn($a, $b) =>
                ($w[$a['priority'] ?? 'normal'] ?? 1) - ($w[$b['priority'] ?? 'normal'] ?? 1));
            break;
        case 'alpha':
            usort($tasks, fn($a, $b) => strcasecmp($a['text'] ?? '', $b['text'] ?? ''));
            break;
        case 'order':
            usort($tasks, function($a, $b) {
                $ao = isset($a['order']) ? (int)$a['order'] : PHP_INT_MAX;
                $bo = isset($b['order']) ? (int)$b['order'] : PHP_INT_MAX;
                return $ao - $bo;
            });
            break;
        case 'completed_asc':
            if ($pool === 'completed') {
                usort($tasks, fn($a, $b) => strcmp($a['completed'] ?? '', $b['completed'] ?? ''));
            }
            break;
        case 'completed_desc':
            if ($pool === 'completed') {
                usort($tasks, fn($a, $b) => strcmp($b['completed'] ?? '', $a['completed'] ?? ''));
            }
            break;
        case 'due_asc':
            usort($tasks, function($a, $b) {
                $ad = isset($a['due']) && $a['due'] !== '' ? strtotime($a['due']) : PHP_INT_MAX;
                $bd = isset($b['due']) && $b['due'] !== '' ? strtotime($b['due']) : PHP_INT_MAX;
                return $ad - $bd;
            });
            break;
        case 'due_desc':
            usort($tasks, function($a, $b) {
                $ad = isset($a['due']) && $a['due'] !== '' ? strtotime($a['due']) : 0;
                $bd = isset($b['due']) && $b['due'] !== '' ? strtotime($b['due']) : 0;
                return $bd - $ad;
            });
            break;
        // 'default' → no sort, keep storage order
    }
    return $tasks;
}

/* ─── Export ──────────────────────────────────────────────────────── */

function generate_csv(array $list): string {
    $fh = fopen('php://memory', 'w+');
    // UTF-8 BOM for Excel compatibility
    fwrite($fh, "\xEF\xBB\xBF");
    fputcsv($fh, ['status', 'priority', 'order', 'text', 'created', 'completed', 'due']);
    foreach ($list['active'] as $i => $t) {
        fputcsv($fh, [
            'active',
            $t['priority'] ?? 'normal',
            isset($t['order']) ? (int)$t['order'] : ($i + 1),
            $t['text'] ?? '',
            $t['created'] ?? '',
            '',
            $t['due'] ?? '',
        ]);
    }
    foreach ($list['completed'] as $t) {
        fputcsv($fh, [
            'completed',
            $t['priority'] ?? 'normal',
            '',
            $t['text'] ?? '',
            $t['created'] ?? '',
            $t['completed'] ?? '',
            $t['due'] ?? '',
        ]);
    }
    rewind($fh);
    $csv = stream_get_contents($fh);
    fclose($fh);
    return $csv;
}

function generate_markdown(array $list): string {
    $now = date('d F Y, H:i');
    $md  = '# ' . $list['title'] . "\n\n";
    if ($list['description'] ?? '') {
        $md .= '> ' . $list['description'] . "\n\n";
    }
    $md .= '**Owner:** @' . $list['owner'] . "  \n";
    $md .= '**Updated:** ' . date('d F Y, H:i', strtotime($list['updated'])) . "  \n";
    $md .= '**Visibility:** ' . ($list['public'] ? 'Public' : 'Private') . "\n\n";
    $md .= "---\n\n";
    $md .= '## Active Tasks (' . count($list['active']) . ")\n\n";
    if (empty($list['active'])) {
        $md .= "_No active tasks._\n\n";
    } else {
        foreach ($list['active'] as $i => $t) {
            $pri = match($t['priority'] ?? 'normal') {
                'high'  => ' `HIGH`',
                'low'   => ' `LOW`',
                default => '',
            };
            $ord = isset($t['order']) ? ' (' . (int)$t['order'] . ')' : '';
            $due = (!empty($t['due'])) ? ' `DUE:' . date('d M Y H:i', strtotime($t['due'])) . '`' : '';
            $md .= '- [ ] ' . $t['text'] . $pri . $ord . $due . "\n";
            $md .= '  _Added ' . date('d M Y, H:i', strtotime($t['created'])) . "_\n";
        }
    }
    $md .= "\n---\n\n";
    $md .= '## Completed (' . count($list['completed']) . ")\n\n";
    if (empty($list['completed'])) {
        $md .= "_No completed tasks._\n\n";
    } else {
        foreach ($list['completed'] as $t) {
            $md .= '- [x] ' . $t['text'] . "\n";
            $md .= '  _Completed ' . date('d M Y, H:i', strtotime($t['completed'] ?? '')) . "_\n";
        }
    }
    $md .= "\n---\n_Generated by TaskGram on $now_\n";
    return $md;
}

/* ─── Import ──────────────────────────────────────────────────────── */

function parse_csv_import(string $content): array {
    $fh = fopen('php://memory', 'r+');
    // Strip BOM if present
    $content = ltrim($content, "\xEF\xBB\xBF");
    fwrite($fh, $content);
    rewind($fh);

    $tasks      = [];
    $header_map = null;

    while (($row = fgetcsv($fh)) !== false) {
        if ($row === [null]) continue;

        if ($header_map === null) {
            $lower = array_map(fn($c) => strtolower(trim($c ?? '')), $row);
            // Detect header row: must contain 'text' or 'task'
            if (in_array('text', $lower, true) || in_array('task', $lower, true)) {
                $header_map = array_flip($lower);
                continue;
            }
            $header_map = false; // no header
        }

        if ($header_map === false) {
            // Headerless: col0=text, col1=status, col2=priority
            $text      = trim($row[0] ?? '');
            $status    = strtolower(trim($row[1] ?? 'active'));
            $priority  = strtolower(trim($row[2] ?? 'normal'));
            $created   = '';
            $completed = '';
            $order     = null;
        } else {
            $col = fn(string $k) => trim($row[$header_map[$k] ?? -1] ?? '');
            $text      = $col('text') ?: $col('task') ?: $col('title');
            $status    = strtolower($col('status'));
            $priority  = strtolower($col('priority'));
            $created   = $col('created');
            $completed = $col('completed');
            $raw_order = $col('order');
            $order     = $raw_order !== '' ? max(1, (int)$raw_order) : null;
            $due_raw   = $col('due');
        }

        $text = trim($text);
        if ($text === '') continue;

        $is_done  = in_array($status, ['completed','done','x','[x]','yes','1','true'], true);
        $priority = in_array($priority, ['high','normal','low'], true) ? $priority : 'normal';

        $task = [
            'text'     => $text,
            'priority' => $priority,
            'created'  => ($created !== '') ? $created : date('Y-m-d H:i:s'),
            '_status'  => $is_done ? 'completed' : 'active',
        ];
        if ($is_done && $completed !== '') $task['completed_at'] = $completed;
        if ($order !== null) $task['order'] = $order;
        if (!empty($due_raw ?? '')) {
            $parsed_due = parse_due_input($due_raw);
            if ($parsed_due !== '') $task['due'] = $parsed_due;
        }
        $tasks[] = $task;
    }
    fclose($fh);
    return $tasks;
}

function parse_txt_import(string $content): array {
    $tasks = [];
    $lines = explode("\n", str_replace(["\r\n", "\r"], "\n", $content));

    foreach ($lines as $line) {
        $line = trim($line);
        if ($line === '' || str_starts_with($line, '#')) continue;

        $status   = 'active';
        $priority = 'normal';

        // Markdown checkbox variants: - [x], * [x], - [ ], * [ ]
        if (preg_match('/^[-*+]\s*\[x\]\s*/i', $line, $m)) {
            $status = 'completed';
            $line   = substr($line, strlen($m[0]));
        } elseif (preg_match('/^[-*+]\s*\[\s*\]\s*/', $line, $m)) {
            $line = substr($line, strlen($m[0]));
        } elseif (preg_match('/^[-*+]\s+/', $line, $m)) {
            $line = substr($line, strlen($m[0]));
        }

        // Priority prefix [HIGH] / [LOW]
        if (preg_match('/^\[HIGH\]\s*/i', $line, $m)) {
            $priority = 'high';
            $line     = substr($line, strlen($m[0]));
        } elseif (preg_match('/^\[LOW\]\s*/i', $line, $m)) {
            $priority = 'low';
            $line     = substr($line, strlen($m[0]));
        }
        // Priority suffix !high / !low / !normal
        if (preg_match('/\s+!high\s*$/i', $line)) {
            $priority = 'high';
            $line     = trim(preg_replace('/\s+!high\s*$/i', '', $line));
        } elseif (preg_match('/\s+!low\s*$/i', $line)) {
            $priority = 'low';
            $line     = trim(preg_replace('/\s+!low\s*$/i', '', $line));
        }

        $line = trim($line);
        if ($line === '') continue;

        $tasks[] = [
            'text'     => $line,
            'priority' => $priority,
            'created'  => date('Y-m-d H:i:s'),
            '_status'  => $status,
        ];
    }
    return $tasks;
}

function apply_import_tasks(string $username, string $list_id, array $parsed, bool $append): array {
    $list = get_list($username, $list_id);
    if (!$list) return ['added' => 0, 'error' => 'List not found'];

    if (!$append) {
        $list['active']    = [];
        $list['completed'] = [];
    }

    $added = 0;
    foreach ($parsed as $t) {
        $task = [
            'id'       => bin2hex(random_bytes(6)),
            'text'     => $t['text'],
            'priority' => $t['priority'],
            'created'  => $t['created'] ?? date('Y-m-d H:i:s'),
        ];
        if (isset($t['order'])) $task['order'] = $t['order'];
        if (isset($t['due']))   $task['due']   = $t['due'];

        if (($t['_status'] ?? 'active') === 'completed') {
            $task['completed'] = $t['completed_at'] ?? date('Y-m-d H:i:s');
            $list['completed'][] = $task;
        } else {
            $list['active'][] = $task;
        }
        $added++;
    }

    $list['updated'] = date('Y-m-d H:i:s');
    save_list($list);
    return ['added' => $added, 'error' => null];
}

/* ─── URL helpers ─────────────────────────────────────────────────── */

function list_url(string $owner, string $list_id, array $extra = []): string {
    // Prefer slug-based clean URL when no special params are needed
    if (empty($extra)) {
        $list = get_list($owner, $list_id);
        if ($list && !empty($list['slug'])) {
            return '@' . rawurlencode($owner) . '/' . rawurlencode($list['slug']);
        }
    }
    // Fall back to query-string URL (used for ?edit=1, ?edit_task=X, etc.)
    $params = array_merge(['owner' => $owner, 'id' => $list_id], $extra);
    return 'list.php?' . http_build_query($params);
}

function redirect(string $url): never {
    header('Location: ' . $url);
    exit;
}

function redirect_back(string $fallback = 'dashboard.php'): never {
    redirect($_SERVER['HTTP_REFERER'] ?? $fallback);
}
Ready
GitGram