/ src / controllers / ApiController.php
<?php
/**
 *  SeekQuarry/Yioop --
 *  Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 *  Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 *  LICENSE:
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 *  END LICENSE
 *
 *  @author Eswara Rajesh Pinapala epinapala@live.com
 *  @license https://www.gnu.org/licenses/ GPL3
 *  @link https://www.seekquarry.com/
 *  @copyright 2009 - 2026
 *  @filesource
 */
namespace seekquarry\yioop\controllers;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\mail\UnsubscribeToken;

/**
 * Controller used mainly for handling JS requests for fragments of a web page.
 * For example, Help Wiki Pages or search results to be used as part of
 * continuous scroll
 *
 * @author Eswara Rajesh Pinapala
 */
class ApiController extends Controller implements CrawlConstants
{
    /**
     * Associative array of $components activities for this controller
     * Components are collections of activities (a little like traits) which
     * can be reused.
     *
     * @var array
     */
    public static $component_activities = [  "social" => ["wiki"] ];
    /**
     * These are the activities supported by this controller
     * @var array
     */
    public $activities = ["summarize", "transcribe", "transcriptionStatus",
        "translate", "unsubscribe"];
    /**
     * Used to process requests related to user group activities outside of
     * the admin panel setting. This either could be because the admin panel
     * is "collapsed" or because the request concerns a wiki page.
     *
     * @return mixed configureRequest result when no profile is
     *      configured; under HTTP this typically exits via
     *      displayView or redirectLocation without returning
     */
    public function processRequest()
    {
        $data = [];
        if (!C\PROFILE) {
            return $this->configureRequest();
        }
        if (isset($_SESSION['USER_ID'])) {
            if ($this->getCSRFTime(C\p('CSRF_TOKEN')) == 0 &&
                $_SERVER['REQUEST_METHOD'] == "GET") {
                $_REQUEST[C\p('CSRF_TOKEN')] = $this->generateCSRFToken(
                    $_SESSION['USER_ID']);
                $this->redirectLocation(C\SHORT_BASE_URL . "?" .
                    http_build_query($_REQUEST));
                exit();
            }
            $user_id = $_SESSION['USER_ID'];
            $data['ADMIN'] = 1;
        } else {
            $user_id = L\remoteAddress();
        }
        $data['SCRIPT'] = "";
        $token_okay = $this->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id);
        $data = array_merge($data, $this->processSession());
        if (isset($data["VIEW"])) {
            $view = $data["VIEW"];
        } else {
            $view = 'api';
        }
        $_SESSION['REMOTE_ADDR'] = L\remoteAddress();
        $this->displayView($view, $data);
    }
    /**
     * Used to perform the actual activity call to be done by the
     * api_controller.
     * processSession is called from @see processRequest, which does some
     * cleaning of fields if the CSRFToken is not valid. It is more likely
     * that that api_controller may be involved in such requests as it can
     * be invoked either when a user is logged in or not and for users with and
     * without accounts. processSession makes sure the $_REQUEST'd activity is
     * valid (or falls back to groupFeeds) then calls it. If someone uses
     * the Settings link to change the language or default number of feed
     * elements to view, this method sets up the $data variable so that
     * the back/cancel button on that page works correctly.
     *
     * @return array $data field variables produced by the dispatched
     *      activity (one of "summarize", "transcribe",
     *      "transcriptionStatus", "translate", or the wiki fallback),
     *      augmented with PAGE_TITLE and ACTIVITY_METHOD
     */
    public function processSession()
    {
        if (isset($_REQUEST['a']) &&
                in_array($_REQUEST['a'], $this->activities)) {
            $activity = $this->clean($_REQUEST['a'], "string");
        } else {
            $activity = "wiki";
        }
        $data = $this->call($activity);
        $data['PAGE_TITLE'] = $data['PAGE_TITLE'] ??
            $this->clean($_REQUEST['page_name'], "string");
        $data['ACTIVITY_METHOD'] = $activity;
        if (!is_array($data)) {
            $data = [];
        }
        return $data;
    }
    /**
     * Used to get the messages for a given thread id.
     *
     * @param string $thread_id The id of the thread to get messages for.
     * @return array $messages containing the messages for the thread
     */
    private function getMessages($thread_id)
    {
        $group_model = $this->model("group");
        $user_id = isset($_SESSION['USER_ID']) ?
            $_SESSION['USER_ID'] : C\PUBLIC_USER_ID;
        $search_array = [ ["parent_id", "=", $thread_id, ""] ];
        $limit = 0;
        $results_per_page = C\MAX_SUMMARIZE_MESSAGES;
        /* -2 is just_thread case */
        $for_group = -2;
        $sort = "ksort";
        $messages = $group_model->getGroupItems($limit, $results_per_page,
            $search_array, $user_id, $for_group);
        return $messages;
    }
    /**
     * Handles a request to summarize a thread using the LLM.
     *
     * Expected parameters:
     * - thread_id: The id of the thread to summarize.
     *
     * @return array $data containing summary results and status
     */
    public function summarize()
    {
        $data = [];
        $data['ELEMENT'] = 'summarize';
        if (empty($_REQUEST['thread_id'])) {
            $data['ERRORS'] = ["Missing parameter - 'thread_id' required"];
            return $data;
        }
        $thread_id = $this->clean($_REQUEST['thread_id'], "string");
        $user_locale = L\getLocaleTag();
        $messages = $this->getMessages($thread_id);
        if (empty($messages)) {
            $data['ERRORS'] = ["No messages found for thread id $thread_id"];
            return $data;
        }
        $text = "";
        foreach ($messages as $msg) {
            $pretty_date = date("r", $msg['PUBDATE']);
            $username = $msg['USER_NAME'];
            $content = $msg['DESCRIPTION'];
            $text .= "On {$pretty_date}, {$username} wrote: \"{$content}\"\n\n";
        }
        $target_language = $this->getLanguageFromLocale($user_locale);
        $input_text = "\n<in>" . $text . "</in>\n";
        $summarize_prompt = sprintf(
            "Summarize the following thread concisely in %s, " .
            "capturing the key points:%s IMPORTANT: You MUST output " .
            "ONLY the summary between <out></out> tags. Do not include " .
            "any other text, commentary, or explanations outside these " .
            "tags.",
            $target_language, $input_text);
        $api_url = C\LLM_API_URL;
        $llm_model = C\LLM_MODEL;
        $request_body = [
            "model" => $llm_model,
            "messages" => [
                ["role" => "system", "content" => "You are a helpful assistant "
                . "that summarizes threads concisely. You MUST always format "
                . "your response with the summary inside <out></out> "
                . "tags only. "
                . "Never include any text outside these tags."],
                ["role" => "user", "content" => $summarize_prompt]
            ],
            "temperature" => 0.1,
            "max_tokens" => -1,
            "stream" => false
        ];
        $result = $this->sendLLMRequest($api_url, $request_body);
        if (!$result) {
            $data['ERRORS'] = ["Failed to connect to summarization service"];
        } else {
            $decoded = json_decode($result, true);
            if (isset($decoded['choices'][0]['message']['content'])) {
                $content = $decoded['choices'][0]['message']['content'];
                preg_match('/<out>([\s\S]*?)<\/out>/', $content, $matches);
                if (isset($matches[1])) {
                    $data['SUMMARY'] = trim($matches[1]);
                    $data['STATUS'] = 'success';
                } else {
                    $data['ERRORS'] =
                        ["Summary format not recognized - missing " .
                        "<out> tags"];
                }
            } else {
                $data['ERRORS'] = ["Invalid response from LLM"];
            }
        }
        return $data;
    }
    /**
     * Maps Yioop locale tags to human-readable language names for LLM prompts
     *
     * @param string $locale_tag The Yioop locale tag (e.g., 'en_US', 'es')
     * @return string The language name for LLM instruction
     */
    private function getLanguageFromLocale($locale_tag)
    {
        $language_map = [
            'ar' => 'Arabic',
            'bn' => 'Bengali',
            'de' => 'German',
            'el_GR' => 'Greek',
            'en_US' => 'English',
            'es' => 'Spanish',
            'fa' => 'Persian',
            'fr_FR' => 'French',
            'he' => 'Hebrew',
            'hi' => 'Hindi',
            'id' => 'Indonesian',
            'it' => 'Italian',
            'ja' => 'Japanese',
            'kn' => 'Kannada',
            'ko' => 'Korean',
            'nl' => 'Dutch',
            'pl' => 'Polish',
            'pt' => 'Portuguese',
            'ru' => 'Russian',
            'te' => 'Telugu',
            'th' => 'Thai',
            'tl' => 'Filipino',
            'tr' => 'Turkish',
            'vi_VN' => 'Vietnamese',
            'zh_CN' => 'Chinese'
        ];

        return isset($language_map[$locale_tag]) ?
            $language_map[$locale_tag] : 'English';
    }
    /**
     * Helper method to send API requests to LLM service
     *
     * @param string $url The API endpoint URL
     * @param array $data The request data to send
     * @return string|bool The response body or false on failure
     */
    private function sendLLMRequest($url, $data)
    {
        $post_data = json_encode($data);
        $headers = ['Content-Type: application/json'];
        $response = L\FetchUrl::getPage($url, $post_data, true, null,
            C\SINGLE_PAGE_TIMEOUT, $headers);
        if ($response === false) {
            error_log("LLM API request failed");
            return false;
        }
        return $response;
    }
    /**
     * Handles request to transcribe an audio file
     * Creates a transcription task for background processing by MediaUpdater
     *
     * @return array Response with success status and any error messages
     */
    public function transcribe()
    {
        $data = ['success' => false, 'errors' => []];
        if (!$this->checkCSRFToken()) {
            $data['errors'][] = tl('api_invalid_csrf');
            $this->returnJsonResponse($data);
            return $data;
        }
        if (!C\nsdefined('WHISPER')) {
            $data['errors'][] = tl('api_transcription_not_configured');
            $this->returnJsonResponse($data);
            return $data;
        }
        $json = file_get_contents('php://input');
        $task_data = json_decode($json, true);
        if (!$task_data) {
            $data['errors'][] = tl('api_invalid_request_data');
            $this->returnJsonResponse($data);
            return $data;
        }
        $required_fields = ['audio_path', 'filename', 'task_id'];
        foreach ($required_fields as $field) {
            if (!isset($task_data[$field]) || empty($task_data[$field])) {
                $data['errors'][] = tl('api_missing_required_field', $field);
                $this->returnJsonResponse($data);
                return $data;
            }
        }
        $task_data['audio_path'] = $this->clean($task_data['audio_path'],
            'string');
        $task_data['filename'] = $this->clean($task_data['filename'],
            'string');
        $task_data['task_id'] = $this->clean($task_data['task_id'], 'string');
        if (!file_exists($task_data['audio_path']) ||
            !is_readable($task_data['audio_path'])) {
            $data['errors'][] = tl('api_audio_file_not_accessible');
            $this->returnJsonResponse($data);
            return $data;
        }
        $file_size = filesize($task_data['audio_path']);
        if ($file_size > C\MAX_AUDIO_TRANSCRIBE_SIZE) {
            $data['errors'][] = tl('api_audio_file_too_large');
            $this->returnJsonResponse($data);
            return $data;
        }
        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
        if (!file_exists($task_dir)) {
            if (!mkdir($task_dir, 0777, true)) {
                $data['errors'][] = tl('api_failed_create_directory');
                $this->returnJsonResponse($data);
                return $data;
            }
        }
        $task_file = $task_dir . "/" . $task_data['task_id'] . ".txt";
        $status_file = $task_dir . "/" . $task_data['task_id'] . ".status";
        if (file_exists($task_file)) {
            $data['errors'][] = tl('api_transcription_task_exists');
            $this->returnJsonResponse($data);
            return $data;
        }
        $task_data['created_at'] = time();
        $task_data['status'] = 'pending';
        $task_data['retry_count'] = 0;
        if (file_put_contents($task_file, serialize($task_data)) === false) {
            $data['errors'][] = tl('api_failed_create_task_file');
            $this->returnJsonResponse($data);
            return $data;
        }
        if (file_put_contents($status_file, 'pending') === false) {
            $data['errors'][] = tl('api_failed_create_status_file');
            unlink($task_file);
            $this->returnJsonResponse($data);
            return $data;
        }
        $data['success'] = true;
        $data['task_id'] = $task_data['task_id'];
        $data['message'] = tl('api_transcription_task_created');
        L\crawlLog("AudioTranscription: Task created for " .
            $task_data['filename'] . " (ID: " . $task_data['task_id'] . ")");
        $this->returnJsonResponse($data);
        return $data;
    }
    /**
     * Checks the status of a transcription task
     * Returns current status and transcript content if available
     *
     * @return array Response with task status and transcript if available
     */
    public function transcriptionStatus()
    {
        $data = ['success' => false, 'errors' => []];
        if (!$this->checkCSRFToken()) {
            $data['errors'][] = tl('api_invalid_csrf');
            $this->returnJsonResponse($data);
            return $data;
        }
        $task_id = $this->clean($_REQUEST['task_id'] ?? '', 'string');
        if (!$task_id) {
            $data['errors'][] = tl('api_invalid_task_id');
            $this->returnJsonResponse($data);
            return $data;
        }
        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
        $status_file = $task_dir . "/" . $task_id . ".status";
        $task_file = $task_dir . "/" . $task_id . ".txt";
        if (!file_exists($status_file)) {
            $data['errors'][] = tl('api_task_not_found');
            $this->returnJsonResponse($data);
            return $data;
        }
        $status = file_get_contents($status_file);
        $data['status'] = $status;
        $data['success'] = true;
        if ($status === 'completed') {
            if (file_exists($task_file)) {
                $task_data = unserialize(file_get_contents($task_file));
                if ($task_data && isset($task_data['audio_path'])) {
                    $transcript_path = dirname($task_data['audio_path']) . "/" .
                        pathinfo($task_data['filename'],
                            PATHINFO_FILENAME) . ".txt";
                    if (file_exists($transcript_path)) {
                        $transcript_content =
                            file_get_contents($transcript_path);
                        if ($transcript_content !== false) {
                            $data['transcript'] = trim($transcript_content);
                        } else {
                            $data['status'] = 'failed';
                            $data['errors'][] =
                                tl('api_failed_read_transcript');
                        }
                    } else {
                        $data['status'] = 'failed';
                        $data['errors'][] = tl('api_transcript_not_found');
                    }
                }
            }
        } elseif ($status === 'failed') {
            $data['errors'][] = tl('api_transcription_failed');
        } elseif ($status === 'processing') {
            $data['message'] = tl('api_transcription_processing');
        } elseif ($status === 'pending') {
            $data['message'] = tl('api_transcription_queued');
        }
        $this->returnJsonResponse($data);
        return $data;
    }
    /**
     * Handles a request to translate text from one language to another
     *
     * @return array $data containing translation results and status
     */
    public function translate()
    {
        $data = [];
        $data['ELEMENT'] = 'translate';
        if (empty($_REQUEST['text']) || empty($_REQUEST['target_lang'])) {
            $data['errors'] = ["Missing required parameters: 'text' and " .
                "'target_lang' are required"];
            return $data;
        }
        $text = $this->clean($_REQUEST['text'], "string");
        $target_lang = $this->clean($_REQUEST['target_lang'], "string");

        return $this->translateText($text, $target_lang);
    }
    /**
     * Translates a given text to the specified target language.
     *
     * @param string $text The text to translate
     * @param string $target_lang The target language code
     * @return array Translation results with status and content
     */
    public function translateText($text, $target_lang)
    {
        $data = [];
        $data['ELEMENT'] = 'translate';
        if (empty($text) || empty($target_lang)) {
            $data['errors'] = ["Missing required parameters: 'text' and " .
                "'target_lang' are required"];
            return $data;
        }
        $api_url = C\LLM_API_URL ??
            "http://localhost:1234/v1/chat/completions";
        $llm_model = C\LLM_MODEL ?? "aya-23-8b";
        $language_map = [
            "ar" => "Arabic", "bn" => "Bengali", "de" => "German",
            "el_GR" => "Greek", "en_US" => "English (US)", "es" => "Spanish",
            "fa" => "Persian", "fr_FR" => "French (France)", "he" => "Hebrew",
            "hi" => "Hindi", "id" => "Indonesian", "it" => "Italian",
            "ja" => "Japanese", "kn" => "Kannada", "ko" => "Korean",
            "nl" => "Dutch", "pl" => "Polish", "pt" => "Portuguese",
            "ru" => "Russian", "te" => "Telugu", "th" => "Thai",
            "tl" => "Tagalog", "tr" => "Turkish", "vi_VN" => "Vietnamese",
            "zh_CN" => "Chinese (Simplified)"
        ];
        $mapping_string = "Use the following language mapping for " .
            "accurate translations:\n";
        foreach ($language_map as $code => $lang) {
            $mapping_string .= "$code → $lang\n";
        }
        $translate_prompt = "Translate the text that is in following " .
            "sequence of <in></in> tags to the locale " . $target_lang .
            ". Assume the context is text that might appear in a " .
            "chat message. You should decode any html entities before " .
            "doing the translation. If you see text like '%s' in the " .
            "input treat it like a placeholder that should appear " .
            "verbatim in the appropriate place in the output. Output " .
            "the results between <out></out> tags. Do not add any " .
            "explanations. Do not repeat the output translations. " .
            "<in>" . $text . "</in>";
        $request_body = ["model" => $llm_model,
            "messages" => [["role" => "system",
            "content" => "You are a helpful assistant who translates text" .
            " from one language to another. Use this predefined mapping " .
            "for accurate language detection:\n" . $mapping_string],
            ["role" => "user", "content" => $translate_prompt]],
            "temperature" => 0.1, "max_tokens" => -1, "stream" => false];
        $result = $this->sendLLMRequest($api_url, $request_body);
        if (!$result) {
            $data['errors'] = ["Failed to connect to translation service"];
        } else {
            $decoded = json_decode($result, true);
            if (isset($decoded['choices'][0]['message']['content'])) {
                $content = $decoded['choices'][0]['message']['content'];
                preg_match('/<out>([\s\S]*?)<\/out>/', $content, $matches);
                if (isset($matches[1])) {
                    $data['translation'] = trim($matches[1]);
                    $data['status'] = 'success';
                } else {
                    $data['errors'] = ["Translation format not recognized"];
                }
            } else {
                $data['errors'] = ["Invalid response from translation service"];
            }
        }
        return $data;
    }
    /**
     * Handles a request from an email recipient to stop receiving a
     * group's mail. The link carries a signed token that names the user
     * and the group. Without the one-click marker the endpoint shows a
     * short confirmation page with a button, so that automated link
     * scanners cannot unsubscribe someone merely by following the link;
     * when the request carries the one-click marker (sent by the button
     * the page shows, or by a mail client's one-click request) the
     * group's mail is actually turned off for that user. The page itself
     * is drawn by the unsubscribe view, not here.
     *
     * @return array fields the unsubscribe view draws: the state of the
     *      request (invalid link, confirm, or done), the group name, and
     *      the token to repeat on the confirm button
     */
    public function unsubscribe()
    {
        $data = [];
        $data['VIEW'] = "unsubscribe";
        $token = $this->clean($_REQUEST['token'] ?? "", "string");
        $data['UNSUBSCRIBE_TOKEN'] = $token;
        $one_click = isset($_REQUEST['List-Unsubscribe']) &&
            $_REQUEST['List-Unsubscribe'] === "One-Click";
        $parsed = UnsubscribeToken::parse($token);
        $email = ($parsed === false) ?
            UnsubscribeToken::parseEmail($token) : false;
        if ($parsed === false && $email === false) {
            $data['UNSUBSCRIBE_STATE'] = "invalid";
            return $data;
        }
        if ($parsed !== false) {
            $data['UNSUBSCRIBE_SCOPE'] = "group";
            $data['GROUP_NAME'] = $this->clean((string)$this->model("group")
                ->getGroupName($parsed['group_id']), "string");
        } else {
            $data['UNSUBSCRIBE_SCOPE'] = "all";
            $data['UNSUBSCRIBE_EMAIL'] = $this->clean($email, "string");
        }
        if (!$one_click) {
            $data['UNSUBSCRIBE_STATE'] = "confirm";
            return $data;
        }
        if ($parsed !== false) {
            $this->model("group")->setMailSubscription(
                $parsed['user_id'], $parsed['group_id'], 0);
        } else {
            $this->model("mailSuppression")->suppress($email);
        }
        $data['UNSUBSCRIBE_STATE'] = "done";
        return $data;
    }
    /**
     * Helper method to return JSON response with proper headers
     *
     * @param array $data Response data to encode as JSON
     */
    private function returnJsonResponse($data)
    {
        $parent = $this->parent;
        $parent->web_site->header('Content-Type: application/json');
        $parent->web_site->header('Cache-Control: no-cache, must-revalidate');
        $parent->web_site->header('Expires: Sat, 26 Jul 1997 05:00:00 GMT');
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
    }
}
X