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