| Server IP : 188.114.96.3 / Your IP : 216.73.216.143 Web Server : Apache System : Linux sv058.home.net.pl 6.8.2 husak_dirty_pages_fix #1 SMP PREEMPT_DYNAMIC Tue Apr 2 22:06:51 CEST 2024 x86_64 User : ( 3194769) PHP Version : 8.5.12 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /polana/includes/ |
Upload File : |
<?php
declare(strict_types=1);
/** V13 — business blog translation; uses V9 jobs and existing blog_posts language columns. */
require_once __DIR__.'/tklm_v9.php';
if (is_file(__DIR__.'/polana_v14_names.php')) require_once __DIR__.'/polana_v14_names.php';
function p13_blog_stats(): array {
v9_init();
$out=['pending'=>0,'review'=>0,'approved'=>0,'errors'=>0,'processing'=>0,'total'=>0];
foreach(db()->query("SELECT status,attempts,COUNT(*) c FROM tklm_v9_jobs WHERE entity='blog_posts' GROUP BY status,attempts") as $row){
$status=(string)$row['status'];$n=(int)$row['c'];$out['total']+=$n;
if($status==='pending'&&(int)$row['attempts']>=5)$out['errors']+=$n;
elseif(isset($out[$status]))$out[$status]+=$n;
}
return $out;
}
function p13_blog_scan(int $offset=0,int $limit=20): array {
v9_init();
$limit=max(1,min(40,$limit));$offset=max(0,$offset);
$q=db()->query('SELECT id,title,excerpt,content,title_en,title_de,title_es,excerpt_en,excerpt_de,excerpt_es,content_en,content_de,content_es FROM blog_posts ORDER BY id LIMIT '.$limit.' OFFSET '.$offset);
$count=0;$added=0;
$find=db()->prepare("SELECT source_hash,status FROM tklm_v9_jobs WHERE entity='blog_posts' AND entity_id=? AND field=? AND lang=?");
foreach($q->fetchAll(PDO::FETCH_ASSOC) as $post){
$count++;
foreach(['title','excerpt','content'] as $field){
$text=trim((string)($post[$field]??''));if($text==='')continue;
$hash=hash('sha256',$text);
foreach(['en','de','es'] as $lang){
$find->execute([(int)$post['id'],$field,$lang]);$current=$find->fetch(PDO::FETCH_ASSOC);
// Respect manually translated fields without a V9 job.
if(!$current&&trim((string)($post[$field.'_'.$lang]??''))!=='')continue;
if($current&&hash_equals((string)$current['source_hash'],$hash)&&($current['status']??'')!=='')continue;
v9_enqueue('blog_posts',(int)$post['id'],$field,$lang,$text);$added++;
}
}
}
return ['scanned'=>$count,'added'=>$added,'next'=>$count===$limit?$offset+$count:null];
}
function p13_split_text(string $s,int $max=1600): array {
if(strlen($s)<=$max)return [$s];
$parts=[];$buf='';
// Keep paragraph boundaries. For exceptionally long paragraphs, split on UTF-8 boundaries.
$paragraphs=preg_split('/\n\s*\n/u',$s)?:[$s];
foreach($paragraphs as $para){
if($para==='')continue;
if(strlen($para)>$max){
if($buf!==''){$parts[]=$buf;$buf='';}
while(strlen($para)>$max){
$head=mb_strcut($para,0,$max,'UTF-8');
$space=mb_strrpos($head,' ',0,'UTF-8');
if($space!==false&&$space>250)$head=mb_substr($head,0,$space,'UTF-8');
$parts[]=$head;
$para=trim(mb_substr($para,mb_strlen($head,'UTF-8'),null,'UTF-8'));
}
$buf=$para;
}else{
if($buf!==''&&strlen($buf)+strlen($para)+2>$max){$parts[]=$buf;$buf='';}
$buf=$buf===''?$para:$buf."\n\n".$para;
}
}
if($buf!=='')$parts[]=$buf;
return $parts;
}
/**
* V16.2: deterministic, UTF-8-safe fragments, normally <= 420 bytes.
* Preserve original whitespace between fragments to avoid flattening Markdown.
* The API never receives the whole article, and one request handles ONE fragment.
*/
function p13_v162_segments(string $text, int $maxBytes=420): array {
$maxBytes=max(180,min(750,$maxBytes));
// Keep whole protected names and street addresses in ONE fragment.
$named=[];
if(function_exists('p14_terms')){
$named=array_map(static fn($v)=>preg_quote((string)$v,'~'),p14_terms());
}
$street='(?:ul\.|ulica|ulicy|al\.|aleja|alei)\s+[A-ZĄĆĘŁŃÓŚŹŻ][\p{L}\p{M}.\x27-]{1,}(?:\s+[A-ZĄĆĘŁŃÓŚŹŻ][\p{L}\p{M}.\x27-]{1,}){0,3}(?:\s+\d+[A-Za-z]?)?';
$protected='(?<![\p{L}\p{N}])(?:'.$street.($named?'|'.implode('|',$named):'').')(?![\p{L}\p{N}])';
$pattern='~https?://[^\s<>]+|'.$protected.'|\s+|[^\s]+~iu';
$found=preg_match_all($pattern,$text,$matches);
if($found===false)throw new RuntimeException('Niepoprawne UTF-8 artykułu');
$pieces=$matches[0];
if(implode('',$pieces)!==$text)throw new RuntimeException('Błąd dzielenia nazw chronionych');
$segments=[];$buffer='';
foreach($pieces as $piece){
if($piece==='')continue;
if(preg_match('/^\s+$/u',$piece)){
$buffer.=$piece;
continue;
}
if(strlen($piece)>$maxBytes){
throw new RuntimeException('Zbyt długi wyraz lub fragment HTML bez spacji; sprawdź treść artykułu');
}
if(strlen($buffer.$piece)>$maxBytes && trim($buffer)!==''){
preg_match('/(\s*)$/u',$buffer,$m);
$separator=(string)($m[1]??'');
$body=substr($buffer,0,strlen($buffer)-strlen($separator));
if(trim($body)!=='')$segments[]=['text'=>$body,'after'=>$separator];
$buffer=$piece;
}else{$buffer.=$piece;}
}
if(trim($buffer)!==''){
preg_match('/(\s*)$/u',$buffer,$m);
$separator=(string)($m[1]??'');
$body=substr($buffer,0,strlen($buffer)-strlen($separator));
if(trim($body)!=='')$segments[]=['text'=>$body,'after'=>$separator];
}elseif($segments && $buffer!==''){
$segments[count($segments)-1]['after'].=$buffer;
}
if(!$segments)throw new RuntimeException('Pusty tekst do tłumaczenia');
// Guard against malformed splits that could silently omit source content.
$check='';foreach($segments as $part)$check.=$part['text'].$part['after'];
if($check!==$text)throw new RuntimeException('Kontrola dzielenia tekstu nie powiodła się');
return $segments;
}
/** Progress survives PHP timeout, reload, logout and transient HTTP 502. */
function p13_v162_progress_schema():void {
db()->exec("CREATE TABLE IF NOT EXISTS polana_v16_blog_progress (
job_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
source_hash CHAR(64) NOT NULL,
next_segment INT UNSIGNED NOT NULL DEFAULT 0,
segment_total INT UNSIGNED NOT NULL,
translations_json MEDIUMTEXT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
}
function p13_blog_work(): array {
v9_init();
p13_v162_progress_schema();
$db=db();
// The existing claim scheme also recovers requests killed by a PHP timeout.
$db->exec("UPDATE tklm_v9_jobs SET status='pending' WHERE entity='blog_posts' AND status='processing' AND updated_at<DATE_SUB(NOW(),INTERVAL 15 MINUTE)");
// HTTP 502 can mean a temporarily unavailable proxy/NAS. Wait 90 seconds before retry.
$q=$db->query("SELECT j.* FROM tklm_v9_jobs j
WHERE j.entity='blog_posts' AND j.status='pending' AND j.attempts<5
AND NOT (j.last_error LIKE '%HTTP 502%' AND j.updated_at>DATE_SUB(NOW(), INTERVAL 90 SECOND))
ORDER BY j.updated_at,j.id LIMIT 1");
$job=$q->fetch(PDO::FETCH_ASSOC);
if(!$job)return ['attempted'=>0,'translated'=>0,'failed'=>0,'waiting'=>true];
$id=(int)$job['id'];$field=(string)$job['field'];$lang=(string)$job['lang'];
if(!in_array($field,['title','excerpt','content'],true)||!in_array($lang,['en','de','es'],true)){
$db->prepare("UPDATE tklm_v9_jobs SET attempts=5,last_error='Nieznane pole lub język' WHERE id=?")->execute([$id]);
return ['attempted'=>1,'translated'=>0,'failed'=>1];
}
$claim=$db->prepare("UPDATE tklm_v9_jobs SET status='processing',updated_at=NOW() WHERE id=? AND status='pending' AND attempts<5");
$claim->execute([$id]);
if(!$claim->rowCount())return ['attempted'=>0,'translated'=>0,'failed'=>0,'busy'=>true];
try{
$q=$db->prepare("SELECT `$field` FROM blog_posts WHERE id=?");
$q->execute([(int)$job['entity_id']]);$value=$q->fetchColumn();
if(!is_string($value)||hash('sha256',trim($value))!==(string)$job['source_hash']){
if(is_string($value)&&trim($value)!=='')v9_enqueue('blog_posts',(int)$job['entity_id'],$field,$lang,$value);
else $db->prepare("UPDATE tklm_v9_jobs SET status='pending',attempts=5,last_error='Brak oryginału' WHERE id=?")->execute([$id]);
return ['attempted'=>1,'translated'=>0,'failed'=>0,'skipped'=>true];
}
$source=trim($value);
$segments=p13_v162_segments($source);
$total=count($segments);
$q=$db->prepare('SELECT * FROM polana_v16_blog_progress WHERE job_id=?');$q->execute([$id]);
$progress=$q->fetch(PDO::FETCH_ASSOC);
if(!$progress || !hash_equals((string)$progress['source_hash'],(string)$job['source_hash']) || (int)$progress['segment_total']!==$total){
$db->prepare('REPLACE INTO polana_v16_blog_progress(job_id,source_hash,next_segment,segment_total,translations_json) VALUES(?,?,0,?,?)')
->execute([$id,$job['source_hash'],$total,'[]']);
$index=0;$translations=[];
}else{
$index=(int)$progress['next_segment'];
$translations=json_decode((string)$progress['translations_json'],true);
if(!is_array($translations)||count($translations)!==$index||$index>$total){
throw new RuntimeException('Nieprawidłowy zapis postępu: skontroluj tabelę postępu');
}
}
if($index<$total){
$text=(string)$segments[$index]['text'];
$api=new TklmTranslateClient(v9_resolve_translate_key());
// The glossary is optional; preserve named places / streets if available.
$translated=function_exists('p14_translate')
?p14_translate($api,$text,$lang)
:trim((string)($api->translate($text,'pl',$lang)['translatedText']??''));
$translated=trim($translated);
if($translated==='')throw new RuntimeException('API nie zwróciło tekstu fragmentu');
$translations[]=$translated;
$index++;
// Save the successfully translated fragment BEFORE releasing the job.
$q=$db->prepare('UPDATE polana_v16_blog_progress SET next_segment=?,translations_json=? WHERE job_id=? AND source_hash=?');
$q->execute([$index,json_encode($translations,JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE),$id,$job['source_hash']]);
if(!$q->rowCount())throw new RuntimeException('Nie zapisano postępu fragmentu');
}
if($index<$total){
$q=$db->prepare("UPDATE tklm_v9_jobs SET status='pending',attempts=0,last_error=NULL,updated_at=NOW() WHERE id=? AND source_hash=? AND status='processing'");
$q->execute([$id,$job['source_hash']]);
return ['attempted'=>1,'translated'=>0,'failed'=>0,'partial'=>true,'job_id'=>$id,
'lang'=>$lang,'field'=>$field,'entity_id'=>(int)$job['entity_id'],
'segment'=>$index,'segments'=>$total];
}
$target='';
foreach($segments as $i=>$segment)$target.=$translations[$i].$segment['after'];
$target=trim($target);
if($target==='')throw new RuntimeException('Puste tłumaczenie po połączeniu fragmentów');
if($field==='title'&&mb_strlen($target,'UTF-8')>255)throw new RuntimeException('Przekroczono 255 znaków w tytule');
$db->beginTransaction();
try{
$q=$db->prepare("UPDATE tklm_v9_jobs SET status='review',translated_text=?,last_error=NULL,updated_at=NOW() WHERE id=? AND source_hash=? AND status='processing'");
$q->execute([$target,$id,$job['source_hash']]);
if($q->rowCount()!==1)throw new RuntimeException('Zadanie zmienione podczas zapisu');
$db->prepare('DELETE FROM polana_v16_blog_progress WHERE job_id=?')->execute([$id]);
$db->commit();
}catch(Throwable $e){$db->rollBack();throw $e;}
return ['attempted'=>1,'translated'=>1,'failed'=>0,'job_id'=>$id,'lang'=>$lang,
'field'=>$field,'entity_id'=>(int)$job['entity_id'],'segments'=>$total];
}catch(Throwable $e){
if($db->inTransaction())$db->rollBack();
error_log('POLANA V16.2 blog job='.$id.' '.preg_replace('/[a-f0-9]{48}/i','[key]',$e->getMessage()));
$q=$db->prepare("UPDATE tklm_v9_jobs SET status='pending',attempts=attempts+1,last_error=?,updated_at=NOW() WHERE id=? AND status='processing'");
$q->execute([mb_substr($e->getMessage(),0,500),$id]);
return ['attempted'=>1,'translated'=>0,'failed'=>1,'job_id'=>$id,'lang'=>$lang];
}
}
function p13_blog_approve(array $ids): array {
$ids=array_values(array_unique(array_filter(array_map('intval',$ids),static fn($v)=>$v>0)));
if(count($ids)>150)throw new RuntimeException('Limit 150 pozycji jednorazowo');
$s=db()->prepare("SELECT id FROM tklm_v9_jobs WHERE entity='blog_posts' AND status='review' AND id=?");
$n=0;$errors=[];
foreach($ids as $id){
$s->execute([$id]);if(!$s->fetchColumn())continue;
try{v9_approve($id);$n++;}catch(Throwable $e){$errors[]=$id.': '.$e->getMessage();}
}
return ['approved'=>$n,'errors'=>$errors];
}
function p13_blog_enqueue_one(int $id): int {
if($id<=0)return 0;
$q=db()->prepare('SELECT id,title,excerpt,content,title_en,title_de,title_es,excerpt_en,excerpt_de,excerpt_es,content_en,content_de,content_es FROM blog_posts WHERE id=?');
$q->execute([$id]);$post=$q->fetch(PDO::FETCH_ASSOC);if(!$post)return 0;
$count=0;
$existing=db()->prepare("SELECT source_hash,status FROM tklm_v9_jobs WHERE entity='blog_posts' AND entity_id=? AND field=? AND lang=?");
foreach(['title','excerpt','content'] as $field){
$source=trim((string)($post[$field]??''));if($source==='')continue;
$hash=hash('sha256',$source);
foreach(['en','de','es'] as $lang){
$existing->execute([$id,$field,$lang]);$old=$existing->fetch(PDO::FETCH_ASSOC);
if(!$old&&trim((string)($post[$field.'_'.$lang]??''))!=='')continue;
if($old&&hash_equals((string)$old['source_hash'],$hash))continue;
v9_enqueue('blog_posts',$id,$field,$lang,$source);$count++;
}
}
return $count;
}