HEX
Server: Apache
System: Linux server1.ariadata.co 4.18.0-553.120.1.el8_10.x86_64 #1 SMP Mon Apr 20 18:04:27 EDT 2026 x86_64
User: nepi (1051)
PHP: 7.4.33
Disabled: exec,passthru,shell_exec,system,show_source,popen,proc_open
Upload Files
File: //home/nepi/q.nepi.ir/search.php
<?php

declare(strict_types=1);

/**
 * Simple Arabic/Persian text search over SQLite tables: quran, nahj, sahife.
 * - Default field to search: simple (and query gets normalized to match it)
 * - Optional table and field filters
 * - Returns structured JSON with per-table counts
 *
 * Usage (GET or POST):
 *   q=کلمه&table=quran&field=arabic&limit=50&offset=0
 */

header('Content-Type: application/json; charset=utf-8');

const DB_PATH = __DIR__ . '/ayat.db';
const ALLOWED_TABLES = ['quran', 'nahj', 'sahife'];
const ALLOWED_FIELDS = ['arabic', 'persian', 'simple', 'chapter'];

try {
    // ---------- Read inputs ----------
    $q       = trim((string)($_REQUEST['q'] ?? ''));
    $table   = isset($_REQUEST['table']) ? strtolower(trim((string)$_REQUEST['table'])) : null;
    $field   = isset($_REQUEST['field']) ? strtolower(trim((string)$_REQUEST['field'])) : null;
    $limit   = isset($_REQUEST['limit']) ? max(1, (int)$_REQUEST['limit']) : 50;
    $offset  = isset($_REQUEST['offset']) ? max(0, (int)$_REQUEST['offset']) : 0;

    if ($q === '') {
        http_response_code(400);
        echo json_encode(['error' => 'Parameter "q" is required.'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($table !== null && !in_array($table, ALLOWED_TABLES, true)) {
        http_response_code(400);
        echo json_encode(['error' => 'Invalid table. Allowed: quran, nahj, sahife'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($field !== null && !in_array($field, ALLOWED_FIELDS, true)) {
        http_response_code(400);
        echo json_encode(['error' => 'Invalid field. Allowed: arabic, persian, simple, chapter'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    // Default field = simple
    $field = $field ?: 'simple';

    // If searching in simple (or field not specified), normalize the query too
    $needle = ($field === 'simple') ? normalize_arabic_simple($q) : $q;

    // ---------- Open DB ----------
    if (!is_file(DB_PATH)) {
        http_response_code(500);
        echo json_encode(['error' => 'Database file not found: ' . DB_PATH], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $pdo = new PDO('sqlite:' . DB_PATH, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);

    // ---------- Build and run ----------
    $results = [];
    $counts  = ['total' => 0, 'by_table' => new stdClass()];

    // Search in one or all tables
    $tablesToSearch = $table ? [$table] : ALLOWED_TABLES;

    // If searching all, split limit roughly across tables
    $perTableLimit = $table ? $limit : (int)ceil($limit / count($tablesToSearch));
    $perTableOffset = $table ? $offset : 0; // simple approach: no global offset across tables

    foreach ($tablesToSearch as $tbl) {
        // Secure field name (already validated)
        $fld = $field;

        // Count query
        $countSql = "SELECT COUNT(*) AS c FROM {$tbl} WHERE {$fld} LIKE :needle";
        $countStmt = $pdo->prepare($countSql);
        $countStmt->bindValue(':needle', '%' . $needle . '%', PDO::PARAM_STR);
        $countStmt->execute();
        $c = (int)$countStmt->fetchColumn();
        $counts['by_table']->{$tbl} = $c;
        $counts['total'] += $c;

        if ($perTableLimit <= 0) {
            continue;
        }

        // Data query (order by chapter_id, order as integer if exists)
        // All tables have: arabic, persian, chapter (TEXT), chapter_id (INTEGER), order (TEXT), simple (TEXT)
        $sql = "
            SELECT
                :tbl AS table_name,
                chapter,
                chapter_id,
                \"order\" AS verse_order,
                arabic,
                persian,
                simple
            FROM {$tbl}
            WHERE {$fld} LIKE :needle
            ORDER BY
                CASE WHEN chapter_id IS NULL THEN 0 ELSE chapter_id END ASC,
                CASE WHEN \"order\" GLOB '*[^0-9]*' THEN 9999999 ELSE CAST(\"order\" AS INTEGER) END ASC
            LIMIT :lim OFFSET :off
        ";
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue(':tbl', $tbl, PDO::PARAM_STR);
        $stmt->bindValue(':needle', '%' . $needle . '%', PDO::PARAM_STR);
        $stmt->bindValue(':lim', $perTableLimit, PDO::PARAM_INT);
        $stmt->bindValue(':off', $perTableOffset, PDO::PARAM_INT);
        $stmt->execute();

        while ($row = $stmt->fetch()) {
            $results[] = [
                'table'        => $row['table_name'],
                'chapter'      => $row['chapter'],
                'match_field'  => $fld,
                'arabic'       => $row['arabic'],
                'persian'      => $row['persian'],
                // Optional: include simple only if needed; comment next line if you don't want it in response
            ];
        }
    }

    // If searching all tables, you might want to sort merged results by chapter_id/order again
    if (!$table) {
        usort($results, function($a, $b) {
            $ai = $a['chapter_id'] ?? 0;
            $bi = $b['chapter_id'] ?? 0;
            if ($ai === $bi) {
                // numeric compare on order if possible
                $ao = is_numeric($a['order']) ? (int)$a['order'] : PHP_INT_MAX;
                $bo = is_numeric($b['order']) ? (int)$b['order'] : PHP_INT_MAX;
                if ($ao === $bo) {
                    // secondary: table name
                    return strcmp($a['table'], $b['table']);
                }
                return $ao <=> $bo;
            }
            return $ai <=> $bi;
        });
        // Respect overall limit if you need strict global limit
        if (count($results) > $limit) {
            $results = array_slice($results, 0, $limit);
        }
    }

    // ---------- Output ----------
    $out = [
        'query' => [
            'q'      => $q,
            'needle' => $needle,
            'table'  => $table,
            'field'  => $field,
            'limit'  => $limit,
            'offset' => $offset,
        ],
        'counts'  => $counts,
        'results' => $results,
    ];
    echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);

} catch (Throwable $e) {
    http_response_code(500);
    echo json_encode([
        'error' => $e->getMessage(),
        'trace' => getenv('APP_DEBUG') ? $e->getTrace() : null
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

/**
 * Normalize Arabic text similar to the `simple` column logic:
 * - unify letters (alef, ya, kaf, ta marbuta, hamza forms)
 * - remove diacritics and tatweel
 * - remove ZWNJ/ZWJ
 * - convert Arabic/Persian digits to Latin
 * - trim & collapse spaces (light)
 */
function normalize_arabic_simple(string $s): string {
    if ($s === '') return $s;

    // unify alef shapes
    $s = str_replace(["آ","أ","إ","ٱ"], "ا", $s);
    // ta marbuta
    $s = str_replace("ة", "ه", $s);
    // ya variants
    $s = str_replace(["ى","ي"], "ی", $s);
    // kaf arabic -> persian
    $s = str_replace("ك", "ک", $s);
    // hamza on waw/ya
    $s = str_replace(["ؤ"], "و", $s);
    $s = str_replace(["ئ"], "ی", $s);

    // remove tatweel and joiners
    $s = str_replace(["ـ", "\u{200C}", "\u{200D}"], "", $s);

    // remove common diacritics & Quranic signs
    $diacs = ['َ','ً','ُ','ٌ','ِ','ٍ','ْ','ّ','ٓ','ٔ','ٖ','ٗ','ٰ',
              'ۖ','ۗ','ۘ','ۙ','ۚ','ۛ','ۜ','۝','۞','۟','۠','ۡ','ۢ','ۣ','ۤ','ۥ','ۦ','۩','۪','۫','۬','ۭ'];
    $s = str_replace($diacs, "", $s);

    // digits to Latin
    $s = str_replace(['٠','١','٢','٣','٤','٥','٦','٧','٨','٩'], ['0','1','2','3','4','5','6','7','8','9'], $s);
    $s = str_replace(['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'], ['0','1','2','3','4','5','6','7','8','9'], $s);

    // collapse spaces
    $s = trim(preg_replace('/\s+/u', ' ', $s));
    return $s;
}