<?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);
}
}