Core
object
%cookies
/phlo/resources/cookies.phlo
method
%cookies -> controller
line 9
this->objData = $_COOKIEprop
%cookies -> lifetimeDays
line 11
180method
%cookies -> objSet ($key, $value, array $options = [])
line 13
$this->objData[$key] = $value
$_COOKIE[$key] = $value
$defaults = ['expires' => time() + $this->lifetimeDays * 86400, 'path' => slash, 'secure' => %req->secure, 'httponly' => true, 'samesite' => 'Lax']
setcookie($key, $value, array_merge($defaults, $options))
return truemethod
%cookies -> __unset ($key)
line 21
unset($this->objData[$key], $_COOKIE[$key])
$options = ['expires' => time() - 86400, 'path' => slash, 'secure' => %req->secure, 'httponly' => true, 'samesite' => 'Lax']
setcookie($key, void, $options)object
%lang
/phlo/resources/lang.phlo
function
function nl ($text, ...$args)
line 11
%lang->translation('nl', $text, ...$args)function
function en ($text, ...$args)
line 12
%lang->translation('en', $text, ...$args)static
lang :: asyncBatch ($from, $to, $json)
line 14
%app->lang = $to
$texts = json_decode($json, true)
$translations = $this->translateBatch($from, $to, $texts)
if ($translations) $this->save($to, $translations)view
%lang -> view
line 21
%app->langprop
%lang -> model
line 23
'gpt-4o-mini'static
lang :: fileCache
line 24
[]method
%lang -> file ($lang)
line 26
langs.$lang.'.ini'method
%lang -> escape ($value)
line 28
strtr((string)$value, [bs => bs.bs, dq => bs.dq, lf => '\n'])method
%lang -> unescape ($value)
line 29
strtr(strtr($value, [bs.bs => "\x01", bs.dq => dq, '\n' => lf]), ["\x01" => bs])method
%lang -> lineValue ($line, $eq)
line 31
$value = rtrim(substr($line, $eq + 3), cr.lf)
if (strlen($value) > 1 && $value[0] === dq && substr($value, -1) === dq) $value = substr($value, 1, -1)
return $this->unescape($value)method
%lang -> readAll ($file)
line 37
$items = []
if (!is_file($file)) return $items
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] AS $line){
$eq = strpos($line, ' = ')
if ($eq === false) continue
$items[substr($line, 0, $eq)] = $this->lineValue($line, $eq)
}
return $itemsmethod
%lang -> search ($file, $hash)
line 48
$size = (int)@filesize($file)
if (!$size) return null
$h = @fopen($file, 'rb')
if (!$h) return null
$lo = 0
$hi = $size
while ($hi - $lo > 4096){
$mid = intdiv($lo + $hi, 2)
fseek($h, $mid)
fgets($h)
$pos = ftell($h)
if ($pos >= $hi){
$hi = $mid
continue
}
$line = (string)fgets($h)
$eq = strpos($line, ' = ')
if ($eq === false){
$hi = $mid
continue
}
$cmp = strcmp(substr($line, 0, $eq), $hash)
if ($cmp < 0) $lo = ftell($h)
elseif ($cmp > 0) $hi = $pos
else {
fclose($h)
return $this->lineValue($line, $eq)
}
}
fseek($h, $lo)
$value = null
while (ftell($h) < $hi && ($line = fgets($h)) !== false){
$eq = strpos($line, ' = ')
if ($eq === false) continue
$cmp = strcmp(substr($line, 0, $eq), $hash)
if ($cmp > 0) break
if ($cmp === 0){
$value = $this->lineValue($line, $eq)
break
}
}
fclose($h)
return $valuemethod
%lang -> lookup ($hash)
line 94
$file = $this->file(%app->lang)
$mtime = (int)@filemtime($file)
$cache =& static::$fileCache[$file]
if (!$cache || $cache['mtime'] !== $mtime) $cache = ['mtime' => $mtime, 'items' => []]
if (array_key_exists($hash, $cache['items'])) return $cache['items'][$hash]
$value = $this->search($file, $hash)
if ($value === null && $mtime) $value = $this->readAll($file)[$hash] ?? null
return $cache['items'][$hash] = $valuemethod
%lang -> save ($lang, $pairs)
line 105
$file = $this->file($lang)
$items = $this->readAll($file)
foreach ($pairs AS $hash => $value) $items[$hash] = $value
ksort($items, SORT_STRING)
$out = void
foreach ($items AS $hash => $value) $out .= $hash.' = '.dq.$this->escape($value).dq.lf
$tmp = $file.'.'.getmypid().'.tmp'
file_put_contents($tmp, $out, LOCK_EX)
@chmod($tmp, 0664)
rename($tmp, $file)
unset(static::$fileCache[$file])method
%lang -> transContext
line 119
($instr = trim((string)(%app->transInstr ?? void))) !== void ? lf.'Context from the app author about purpose and domain: '.$instr : voidprop
%lang -> browser
line 121
last($langs = array_filter(explode(comma, %req->acceptLanguage), fn($lang) => isset(%app->langs[substr($lang, 0, 2)])), $langs ? substr(current($langs), 0, 2) : null)method
%lang -> cookie
line 122
($lang = %cookies->lang) && %app->langs[$lang] ? $lang : nullmethod
%lang -> detect ($text, $fallback = 'en')
line 123
$res = %OpenAI->chat (
model: $this->model,
system: 'Analyse which language this text is in and return only the ISO 639-1 code of the language, no other data!',
user: $text.lf.lf.'The ISO 639-1 code of the language is: ',
temperature: 0,
)->answer
return strlen($res) === 2 ? strtolower($res) : $fallbackmethod
%lang -> hash ($from, $text)
line 132
$from.($short = substr(implode(regex_all('/[A-Za-z0-9]+/', ucwords($text))[0]), 0, 8)).substr(md5($text), 0, 10 - strlen($short))method
%lang -> translation ($from, $text, ...$args)
line 133
if ($from === %app->lang) $translation = strtr($text, ['\n' => lf])
else {
$translation = []
$missing = []
foreach (explode(lf, $text) AS $line){
if (trim($line)){
$hash = $this->hash($from, $line)
$item = $this->lookup($hash)
if ($item === null) [$missing[$hash] = $item = $line, debug(%app->lang.': '.(strlen($line) > 20 ? substr($line, 0, 18).'...' : $line))]
}
else $item = void
$translation[] = $item
}
if ($missing) phlo_async('lang::asyncBatch', $from, %app->lang, json_encode($missing))
$translation = implode(lf, $translation)
}
return $args ? sprintf($translation, ...$args) : $translationmethod
%lang -> translate ($from, $to, $text)
line 152
if ($from === $to) return $text
return %OpenAI->chat (
model: $this->model,
system: "You will be provided with a word, sentence or (markdown) text in ISO 639-1 language $from, and your task is to translate this string into ISO 639-1 language $to. Respect markdown, missing interpunction and specific use of capitals. Give only the translation.".$this->transContext(),
user: $text,
temperature: 0,
)->answermethod
%lang -> translateBatch ($from, $to, $texts)
line 161
if ($from === $to) return $texts
$hashes = array_keys($texts)
$numbered = implode(lf, array_map(fn($i, $t) => ($i + 1).'. '.$t, array_keys($values = array_values($texts)), $values))
$answer = %OpenAI->chat (
model: $this->model,
system: "You will be provided with numbered lines in ISO 639-1 language $from. Translate each line into ISO 639-1 language $to. Return only the numbered translations in the same format. Respect markdown, missing interpunction and specific use of capitals.".$this->transContext(),
user: $numbered,
temperature: 0,
)->answer
$result = []
foreach (explode(lf, trim($answer)) AS $line){
if (preg_match('/^(\d+)\.\s*(.+)/', $line, $m))
$result[$hashes[(int)$m[1] - 1]] = $m[2]
}
return $resultobject
%payload
/phlo/resources/payload.phlo
method
%payload -> controller
line 10
contentType = %req->contentType
if (in_array(phlo('req')->method, ['POST', 'PUT', 'PATCH']) && str_starts_with($contentType, 'application/json')){
$data = json_read('php://input')
return $this->objData = is_object($data) ? get_object_vars($data) : (is_array($data) ? $data : [])
}
if ($_POST) $this->objImport(...$_POST)
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH']) && str_starts_with($contentType, 'application/x-www-form-urlencoded')){
$body = file_get_contents('php://input')
$data = []
parse_str($body, $data)
if ($data) $this->objImport(...$data)
}
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH']) && str_starts_with($contentType, 'multipart/form-data')){
$match = regex('/boundary="?([^";]+)"?/', $contentType)
if (!$match) return
$boundary = '--'.$match[1]
$arrays = []
$raw = file_get_contents('php://input')
foreach (explode($boundary, $raw) AS $part){
if (!trim($part) || $part === '--' || !str_contains($part, nl.nl)) continue
$headers = []
[$rawHeaders, $body] = explode(nl.nl, $part, 2)
foreach (explode(nl, trim($rawHeaders)) AS $header){
if (str_contains($header, colon)){
[$key, $value] = explode(colon, $header, 2)
$headers[strtolower(trim($key))] = trim($value)
}
}
if (!isset($headers['content-disposition'])) continue
if (!preg_match('/name="([^"]+)"/', $headers['content-disposition'], $match)) continue
$name = $match[1]
$body = rtrim($body, nl)
if ($body === void) $body = null
$base = $name
$keys = []
$hasEmptyIndex = false
if (preg_match('/^([^\[]+)((?:\[[^\]]*\])*)$/', $name, $m)){
$base = $m[1]
$brackets = $m[2]
if ($brackets){
preg_match_all('/\[([^\]]*)\]/', $brackets, $mm)
$keys = $mm[1]
$hasEmptyIndex = in_array(void, $keys, true)
}
}
if ($hasEmptyIndex) $arrays[] = $base
$assign = function($value) use ($base, $keys){
if ($keys){
if (!isset($this->objData[$base]) || !is_array($this->objData[$base])) $this->objData[$base] = []
$ref =& $this->objData[$base]
$count = count($keys)
foreach ($keys AS $i => $k){
$last = $i === $count - 1
if ($k === void){
if ($last) $ref[] = $value
else {
$ref[] = []
end($ref)
$idx = key($ref)
$ref =& $ref[$idx]
}
}
else {
if ($last) $ref[$k] = $value
else {
if (!isset($ref[$k]) || !is_array($ref[$k])) $ref[$k] = []
$ref =& $ref[$k]
}
}
}
}
else $this->objData[$base] = $value
};
if (preg_match('/filename="([^"]*)"/', $headers['content-disposition'], $f)){
if ($f[1] === void || $body === null){
if (!$hasEmptyIndex) $assign(null)
continue
}
$filename = $f[1]
$file = %file(tempnam(sys_get_temp_dir(), 'phlo'), $filename, $body)
$assign($file)
}
else $assign($body)
}
foreach ($this->objData AS $key => $val){
if (str_ends_with($key, '[]')){
unset($this->objData[$key])
$this->objData[substr($key, 0, -2)] = is_array($val) ? array_values(array_filter($val, fn($v) => $v !== null)) : [$val]
}
elseif (!is_array($val) && substr($key, -2) === '[]') $this->objData[$key] = [$val]
}
foreach (array_unique($arrays) AS $key) if (!isset($this->objData[$key])) $this->objData[$key] = []
}
if ($_FILES) $this->objImport(...loop($_FILES, fn($f) => is_array($f['name']) ? loop(array_keys($f['name']), fn($i) => $f['error'][$i] ? null : %file($f['tmp_name'][$i], $f['name'][$i], mime: $f['type'][$i], size: $f['size'][$i])) : ($f['error'] ? null : %file($f['tmp_name'], $f['name'], mime: $f['type'], size: $f['size']))))object
%session
/phlo/resources/session.phlo
method
%session -> controller
line 9
ession_start()
$this->objData = $_SESSIONmethod
%session -> __set ($key, $value)
line 12
$_SESSION[$key] = $this->objData[$key] = $valuemethod
%session -> __unset ($key)
line 13
unset($this->objData[$key], $_SESSION[$key])method
%session -> __isset ($key)
line 14
isset($this->objData[$key])method
%session -> objRegenerateId ($deleteOld = true)
line 16
session_regenerate_id($deleteOld)
$this->objData = $_SESSIONobject
%sitemap
/phlo/resources/sitemap.phlo
route
route GET sitemap.xml
line 10
output($this)method
%sitemap -> intl ($uri)
line 12
(%app->slugs ?? [])[$uri] ?? $uriview
%sitemap -> view
line 14
<?xml version=1.0 encoding="UTF-8"?>
<urlset xmlns=http://www.sitemaps.org/schemas/sitemap/0.9 xmlns:xhtml=http://www.w3.org/1999/xhtml xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance xsi:schemaLocation=http://www.sitemaps.org/schemas/sitemap/0.9+http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd>
<foreach %app->pages AS $uri>
{{ $this->page($uri) }}
</foreach>
</urlset>view
%sitemap -> page ($uri)
line 22
<url>
<loc>%req->base{( $uri ?: slash )}</loc>
<foreach array_keys(%app->langs) AS $lang>
<if $lang === %app->lang>
{{ $this->xlink('x-default', $uri ?: slash) }}
</if>
{{ $this->xlink($lang, $lang === %app->lang ? ($uri ?: slash) : "/$lang".($this->intl($uri) ?: void)) }}
</foreach>
</url>view
%sitemap -> xlink ($lang, $uri)
line 33
<xhtml:link rel=alternate hreflang="$lang" href="%req->base$uri"{{ slash }}>view
%sitemap -> link ($lang, $uri)
line 34
<link rel=alternate hreflang="$lang" href="%req->base$uri">object
%tasks
/phlo/resources/tasks.phlo
static
tasks :: dir
line 9
data.'tasks/'static
tasks :: run
line 11
is_dir(static::dir()) || mkdir(static::dir(), 0755, true)
$now = time()
foreach (%app->tasks ?? [] AS $name => $task){
$task = (object)$task
if (!static::due($name, $task, $now)) continue
if (!static::lock($name)) continue
$schedule = array_intersect_key((array)$task, array_flip(['every', 'daily', 'weekly']))
$do = is_string($task->do) ? $task->do : null
static::saveRun($name, $do, $schedule, static::fire($task->do))
static::markRun($name, $now)
static::unlock($name)
}static
tasks :: saveRun ($name, $do, $schedule, $return)
line 26
json_write(static::dir().$name.'.json', arr(do: $do, schedule: $schedule, return: $return))static
tasks :: due ($name, $task, $now)
line 28
$last = static::lastRun($name)
if (isset($task->every)){
$every = preg_match('/^\d/', $task->every) ? $task->every : '1 '.$task->every
$seconds = strtotime("+$every", 0) ?: 0
return $seconds > 0 && ($now - $last) >= $seconds
}
if (isset($task->daily)){
if (date('H:i', $now) !== $task->daily) return false
return $last < strtotime('today 00:00', $now)
}
if (isset($task->weekly)){
if (date('D H:i', $now) !== date('D H:i', strtotime("$task->weekly today"))) return false
return $last < strtotime('monday this week', $now)
}
return falsestatic
tasks :: fire ($do)
line 46
if ($do instanceof \Closure) return $do()
if (is_string($do) && str_contains($do, '::')){
[$class, $method] = explode('::', $do, 2)
return $class::$method()
}
if (is_string($do)) return phlo($do)
error('Task do must be Closure, "Class::method" string, or resource-name string')static
tasks :: lastRun ($name)
line 56
$file = static::dir().$name.'.last'
return is_file($file) ? (int)file_get_contents($file) : 0static
tasks :: markRun ($name, $ts)
line 61
file_put_contents(static::dir().$name.'.last', (string)$ts, LOCK_EX)static
tasks :: lock ($name)
line 63
$file = static::dir().$name.'.lock'
if (is_file($file) && (time() - filemtime($file)) < 3600) return false
touch($file)
return truestatic
tasks :: unlock ($name)
line 70
@unlink(static::dir().$name.'.lock')object
%useragent
/phlo/resources/useragent.phlo
prop
%useragent -> source
line 9
%req->userAgent ?: nullprop
%useragent -> os
line 11
if (!$this->source) return 'Unknown'
$list = [
'Android' => '/Android/i',
'iPadOS' => '/iPad.*OS/i',
'iOS' => '/iPhone|iPod/i',
'Windows' => '/Windows NT/i',
'macOS' => '/Mac OS X/i',
'ChromeOS' => '/CrOS/i',
'Linux' => '/Linux/i',
]
foreach ($list AS $n => $r) if (preg_match($r, $this->source)) return $n
if (preg_match('/iPad/i',$this->source) && preg_match('/Mac OS X/i',$this->source)) return 'iPadOS'
return 'Unknown'prop
%useragent -> osV
line 27
if (!$this->source) return void
if (preg_match('/(?:Android|OS X|OS|Windows NT)\s*([0-9._]+)/i', $this->source, $m)){
$v = strtr($m[1], [us => dot])
$v = preg_replace('/[^0-9.].*/', void, $v)
$v = preg_replace('/(?:\.0)+$/', void, $v)
return $v
}
return voidprop
%useragent -> osFull
line 38
if (!$this->OS) return 'Unknown'
$v = $this->osV
if (!$v) return $this->OS
$short = preg_replace('/^(\d+\.\d+).*/','$1',$v)
if (preg_match('/\.0$/',$short)) $short = preg_replace('/\.0$/', void, $short)
return trim($this->OS.' '.$short)prop
%useragent -> name
line 47
if (!$this->source) return 'Unknown'
if (preg_match('/\bwv\b/',$this->source) || (preg_match('/Version\/\d+\.\d+/',$this->source) && strpos($this->source,'Chrome/')!==false && strpos($this->source,'Safari/')!==false && strpos($this->source,' Mobile ')!==false)) return 'Android WebView'
if (preg_match('/CriOS\/([0-9.]+)/',$this->source)) return 'Chrome'
if (preg_match('/FxiOS\/([0-9.]+)/',$this->source)) return 'Firefox'
$list = [
'Edge' => '/Edg\/([0-9.]+)/',
'Opera' => '/OPR\/([0-9.]+)/',
'Samsung Internet' => '/SamsungBrowser\/([0-9.]+)/i',
'Chrome' => '/Chrome\/([0-9.]+)/',
'Firefox' => '/Firefox\/([0-9.]+)/',
'Safari' => '/Version\/([0-9.]+).*Safari/i',
]
foreach ($list AS $n => $r) if (preg_match($r, $this->source)) return $n
return 'Unknown'prop
%useragent -> version
line 64
if (!$this->source) return void
if (preg_match('/(?:Edg|OPR|Chrome|Firefox|Version|CriOS|FxiOS|SamsungBrowser)\/([0-9.]+)/', $this->source, $m)){
$v = $m[1]
$v = preg_replace('/[^0-9.].*/', void, $v)
$v = preg_replace('/(?:\.0)+$/', void, $v)
return $v
}
return voidprop
%useragent -> full
line 75
if (!$this->name) return 'Unknown'
$v = $this->version
if (!$v) return $this->name
$short = preg_replace('/^(\d+\.\d+).*/','$1',$v)
if (preg_match('/\.0$/',$short)) $short = preg_replace('/\.0$/', void, $short)
return rtrim($this->name.' '.$short)prop
%useragent -> device
line 84
if (!$this->source) return 'Unknown'
if (preg_match('/iPad|Tablet|Tab|SM-T|Nexus 7|Nexus 10/i', $this->source)) return 'Tablet'
if (preg_match('/Mobile|iPhone|Android.*Mobile|SM-G|Pixel [0-9]/i', $this->source)) return 'Phone'
return 'Desktop'object
%visitors
/phlo/resources/visitors.phlo
static
visitors :: table
line 11
'visitors'static
visitors :: columns
line 12
'id,token,host,page,lang,IP,browser,os,device,requests,state,width,height,referrer,created,changed'static
visitors :: history
line 14
static::records(columns: 'FROM_UNIXTIME(changed, "%Y-%m-%d") AS date,COUNT(DISTINCT token) AS visitors,COUNT(id) AS visits', group: 'date', order: 'date DESC')static
visitors :: online
line 15
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 9)')static
visitors :: lastHour
line 16
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 3600)')static
visitors :: isBot (?string $ua):bool
line 18
if (!$ua) return false
return (bool)preg_match('/bot|crawl|spider|slurp|baiduspider|facebookexternalhit|twitterbot|linkedinbot|curl|wget|python-requests|go-http-client|java\//i', $ua)static
visitors :: parseReferrer (string $url):string
line 23
static $engines = ['google' => 'Google', 'bing' => 'Bing', 'duckduckgo' => 'DuckDuckGo', 'yahoo' => 'Yahoo', 'baidu' => 'Baidu', 'yandex' => 'Yandex', 'ecosia' => 'Ecosia', 'startpage' => 'Startpage', 'brave' => 'Brave', 'kagi' => 'Kagi']
$host = strtolower(preg_replace('/^www\./', '', (string)(parse_url($url, PHP_URL_HOST) ?? '')))
foreach ($engines AS $key => $name) if (str_contains($host, $key)) return 'search:'.$name
return $host ?: substr($url, 0, 100)route
route PUT heartbeat @n,v,l,u,w,h,a,p,r,c
line 30
if (static::isBot(%useragent->source)) return
$consent = (bool)%payload->c
$n = strlen(%payload->n) === 8 ? %payload->n : date('Ymd')
$id = $consent ? token(20, $n.space.%app->token.space.%useragent->source) : token(20, $n.space.date('Ymd').space.%app->token.space.%req->ip)
$data = arr (
token: token(20, (string)(%app->token ?? error('No app token available'))),
host: host,
page: %payload->u,
lang: strlen(%payload->l) === 2 ? %payload->l : %app->lang ?? 'en',
IP: $consent ? %req->ip : void,
browser: $consent ? %useragent->full.(%payload->a ? ' App' : void) : substr(md5((string)%useragent->source), 0, 8),
os: $consent ? %useragent->osFull : void,
device: $consent ? %useragent->device : void,
requests: %payload->p,
state: %payload->v,
width: %payload->w,
height: %payload->h,
changed: time(),
)
$record = static::record(id: $id, columns: 'id,page,referrer')
if (($referrer = %payload->r) && !$record?->referrer && !str_contains($referrer, host)) $data['referrer'] = static::parseReferrer($referrer)
if ($record) static::change('id=?', $id, ...$data)
else static::create(...$data, id: $id, created: time())view
script
line 56
let curpath = app.path
let heartbeatTimeout
const heartbeat = () => delay('heartbeat', 333, () => {
clearTimeout(heartbeatTimeout)
const consent = document.cookie.includes('cookieChoice=all')
if (consent){
const m = document.cookie.match(/phlo_visitor=([a-z0-9]{8})/)
if (m) window.name = m[1]
else {
window.name ||= phlo.token(8)
document.cookie = `phlo_visitor=${window.name};path=/;max-age=${365 * 86400};SameSite=Lax`
}
}
else window.name ||= phlo.token(8)
fetch('/heartbeat', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({n: window.name, v: app.state, l: obj('html').lang ?? 'en', u: app.path, w: innerWidth, h: innerHeight, a: app.mode, p: phlo.state.index, r: (r = document.referrer) ? (r === `${location.origin}/` ? null : r) : null, c: consent ? 1 : 0})})
heartbeatTimeout = setTimeout(heartbeat, 20000)
})
document.addEventListener('visibilitychange', heartbeat)
;['focus', 'blur', 'resize'].forEach(e => addEventListener(e, heartbeat))
app.updates.push(() => curpath !== app.path && (heartbeat(), curpath = app.path))
heartbeat()object
%websocket
/phlo/resources/websocket.phlo
static
websocket :: connect ($host, $token, $socket)
line 10
function_exists('wsConnect') && wsConnect($host, $token, $socket)static
websocket :: auth ($host, $token, $socket)
line 11
function_exists('wsAuth') && wsAuth($host, $token, $socket)static
websocket :: receive ($host, $token, $socket, $data)
line 12
function_exists('wsReceive') && wsReceive($host, $token, $socket, ...json_decode($data, true))static
websocket :: close ($host, $token, $socket)
line 13
function_exists('wsClose') && wsClose($host, $token, $socket)object%WhatsApp
/phlo/resources/WhatsApp.phlo
method
%WhatsApp -> __construct (public string $url, public string $secret)
line 10
$this->url = rtrim($url, slash).slashstatic
WhatsApp :: channel ($channel)
line 12
new static($channel->configData->url ?? 'http://localhost:8081', $channel->secretData->secret ?? void)method
%WhatsApp -> number ($contact)
line 14
($pos = strpos($contact, '@')) ? substr($contact, 0, $pos) : error('Invalid contact: '.esc($contact))method
%WhatsApp -> isGroup ($contact)
line 15
last($this->number($contact), (bool)strpos($contact, '@g'))method
%WhatsApp -> status
line 17
$this->request('status', GET: true)method
%WhatsApp -> health
line 18
$this->request('health', GET: true)method
%WhatsApp -> qr
line 19
$this->request('qr', GET: true)method
%WhatsApp -> disconnect
line 20
$this->request('disconnect')method
%WhatsApp -> read ($chat)
line 22
$this->request('read', chat: $chat)method
%WhatsApp -> reaction ($msg, $emoji)
line 23
$this->request('reaction', msg: $msg, emoji: $emoji)method
%WhatsApp -> text ($to, $text)
line 25
$this->request('text', to: $to, text: $text)method
%WhatsApp -> image ($to, file $file, $text = void)
line 26
$this->request('image', to: $to, filename: $file->name, image: $file->src, text: $text)method
%WhatsApp -> location ($to, $lat, $lon, $text)
line 27
$this->request('location', to: $to, lat: $lat, lon: $lon, text: $text)method
%WhatsApp -> document ($to, file $file, $text = void)
line 28
$this->request('document', to: $to, filename: $file->name, document: $file->src, text: $text)method
%WhatsApp -> audio ($to, file $file)
line 30
$this->request('audio', to: $to, audio: $file->src)method
%WhatsApp -> voice ($to, file $file)
line 31
$this->request('voice', to: $to, audio: $file->src)method
%WhatsApp -> poll ($to, $name, array $options, bool $multi = false)
line 33
$this->request('poll', to: $to, name: $name, options: $options, multi: $multi)method
%WhatsApp -> startTyping ($to)
line 35
$this->request('typing/start', to: $to)method
%WhatsApp -> stopTyping ($to)
line 36
$this->request('typing/stop', to: $to)method
%WhatsApp -> request ($action, ...$data)
line 38
$get = $data['GET'] ?? false
unset($data['GET'])
$raw = trim((string)HTTP($this->url.$action, ['secret: '.$this->secret], true, $get ? null : $data))
if (strtolower($raw) === 'ok') return obj(ok: true)
$res = json_decode($raw)
if (!$res && $raw) return obj(ok: false, error: $raw)
return $res ?: obj(ok: false, error: 'Empty WhatsApp response')Functions
function
active(bool $cond, string $classList = void)
/phlo/resources/active.phlo line 7
$cond || $classList ? ' class="'.$classList.($cond ? ($classList ? space : void).'active' : void).'"' : voidfunction
age(int $time)
/phlo/resources/age.phlo line 7
time() - $timefunction
age_human(int $age)
/phlo/resources/age.human.phlo line 8
time_human(time() - $age)function
apcu($key, $cb, int $duration = 3600, bool $log = true)
/phlo/resources/apcu.phlo line 8
first($value = apcu_entry($key, $cb, $duration), $log && debug('C: '.(strlen($key) > 58 ? substr($key, 0, 55).'...' : $key).(is_array($value) ? ' ('.count($value).')' : (is_numeric($value) ? ":$value" : (is_string($value) ? ':string:'.strlen($value) : colon.gettype($value))))))function
await(...$jobs)
/phlo/resources/await.phlo line 10
$children = []
foreach ($jobs AS $i => $job){
[$cb, $args] = is_array($job) ? [$job[0], array_slice($job, 1)] : [$job, []]
$cmd = cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void)
$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]
$proc = proc_open($cmd, $desc, $pipes)
fclose($pipes[0])
$children[$i] = obj(proc: $proc, out: $pipes[1], err: $pipes[2])
}
$results = []
foreach ($children AS $i => $child){
$out = stream_get_contents($child->out)
$err = stream_get_contents($child->err)
fclose($child->out)
fclose($child->err)
$code = proc_close($child->proc)
$err = trim((string)$err)
if ($err !== void){
$ej = json_decode($err, true)
$results[$i] = json_last_error() === JSON_ERROR_NONE ? $ej : $err
continue
}
if ($code !== 0){
$results[$i] = obj(error: 'CLI process failed', code: $code)
continue
}
$json = json_decode($out, true)
$results[$i] = json_last_error() === JSON_ERROR_NONE ? $json : $out
}
return $resultsfunction
button(...$args):string
/phlo/resources/tags.form.phlo line 10
tag('button', ...$args)function
camel(string $text)
/phlo/resources/camel.phlo line 7
lcfirst(str_replace(space, void, ucwords(lcfirst($text))))function
chunk(...$cmds):void
/phlo/resources/chunk.phlo line 8
$res = %res
$cli = %req->cli
if (debug){
$res->dump && [$cmds['dump'] = $res->dump, $res->dump = []]
$res->debug && [$cmds['debug'] = $res->debug, $res->debug = []]
}
if (!$res->streaming){
$res->streaming = true
$res->type = 'text/event-stream'
$res->header('Cache-Control', 'no-store')
$res->header('X-Content-Type-Options', 'nosniff')
$res->render(206)
}
print(json_encode($cmds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf)
$cli || [@ob_flush(), flush()]function
create(iterable $items, \Closure $keyCb, ?\Closure $valueCb = null)
/phlo/resources/create.phlo line 7
array_combine(loop($items, $keyCb), $valueCb ? loop($items, $valueCb) : $items)function
en($text, ...$args)
/phlo/resources/lang.phlo line 12
%lang->translation('en', $text, ...$args)function
exec_stream(string $cmd, ?int $timeoutSec = 0)
/phlo/resources/exec.stream.phlo line 9
$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]
$proc = proc_open($cmd, $desc, $pipes)
if (!is_resource($proc)) return
stream_set_blocking($pipes[1], false)
stream_set_blocking($pipes[2], false)
$bufOut = void
$bufErr = void
while (true){
$status = proc_get_status($proc)
$running = $status['running']
$read = []
$w = null
$e = null
if (!feof($pipes[1])) $read[] = $pipes[1]
if (!feof($pipes[2])) $read[] = $pipes[2]
if ($read) @stream_select($read, $w, $e, 0, 200000)
foreach ($read AS $r){
$chunk = fread($r, 8192)
if ($chunk === void || $chunk === false) continue
if ($r === $pipes[1]){
$bufOut .= $chunk
while (($pos = strpos($bufOut, lf)) !== false){
$line = substr($bufOut, 0, $pos)
$bufOut = substr($bufOut, $pos + 1)
yield obj(data: $line)
}
}
else {
$bufErr .= $chunk
while (($pos = strpos($bufErr, lf)) !== false){
$line = substr($bufErr, 0, $pos)
$bufErr = substr($bufErr, $pos + 1)
yield obj(data: $line, error: true)
}
}
}
if (!$running) break
if ($timeoutSec > 0 && ($status['running_time'] ?? 0) > $timeoutSec){
proc_terminate($proc)
yield obj(data: 'process timeout', error: true)
break
}
}
if ($bufOut !== void) yield obj(data: $bufOut)
if ($bufErr !== void) yield obj(data: $bufErr, error: true)
foreach ($pipes AS $p) @fclose($p)
proc_close($proc)function
HTTP(string $url, array $headers = [], bool $JSON = false, $POST = null, $PUT = null, $PATCH = null, bool $DELETE = false, ?string $agent = null)
/phlo/resources/HTTP.phlo line 8
$curl = curl_init($url)
if ($POST !== null || $PUT !== null || $PATCH !== null){
if (!is_null($POST)) [$method = 'POST', $content = $POST]
elseif (!is_null($PUT)) [$method = 'PUT', $content = $PUT]
elseif (!is_null($PATCH)) [$method = 'PATCH', $content = $PATCH]
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method)
if ($JSON) [!is_string($content) && $content = json_encode($content), array_push($headers, 'Content-Type: application/json', 'Content-Length: '.strlen($content))]
curl_setopt($curl, CURLOPT_POSTFIELDS, $content)
}
elseif ($DELETE) curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE')
$agent && curl_setopt($curl, CURLOPT_USERAGENT, $agent === true ? phlo('req')->userAgent : $agent)
curl_setopt_array($curl, [CURLOPT_COOKIEFILE => data.'cookies.txt', CURLOPT_COOKIEJAR => data.'cookies.txt', CURLOPT_HTTPHEADER => $headers, CURLOPT_FOLLOWLOCATION => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 15, CURLOPT_ENCODING => void])
$res = curl_exec($curl)
if ($res === false) error('HTTP error: '.curl_error($curl))
return $resfunction
input(...$args):string
/phlo/resources/tags.form.phlo line 11
tag('input', ...$args)function
n8n($webhook, ?array $data = null, $test = false)
/phlo/resources/n8n.phlo line 8
HTTP(%creds->n8n->server.'webhook'.($test ? '-test' : '').'/'.$webhook, POST: $data)function
n8n_test($webhook, ?array $data = null)
/phlo/resources/n8n.test.phlo line 8
n8n($webhook, $data, true)function
nl($text, ...$args)
/phlo/resources/lang.phlo line 11
%lang->translation('nl', $text, ...$args)function
notify(string $title, string $body = '', string $type = 'info', string $level = 'info', ?string $user = null):void
/phlo/resources/notify.phlo line 9
$cfg = %creds->notify ?? null
if (!$cfg) return
$url = (string)($cfg->url ?? void)
$secret = (string)($cfg->secret ?? void)
if ($url === void || $secret === void) return
try {
HTTP($url, ['secret: '.$secret], true, [
'app' => (string)($cfg->app ?? (defined('id') ? id : 'app')),
'server' => (string)($cfg->server ?? 'local'),
'host' => (string)(%req->host ?? ''),
'type' => $type,
'level' => $level,
'title' => $title,
'body' => $body,
'user' => $user,
])
}
catch (\Throwable $e){}function
phlo(?string $phloName = null, ...$args):mixed
/phlo/phlo.php line 190
static $list = [];
if ($phloName === 'tech/reset') return array_keys($list = array_filter($list, static fn($obj) => $obj->objPers));
if ($phloName === null) return array_keys($list);
$class = strtr($phloName, [slash => us]);
$handle = method_exists($class, '__handle') ? $class::__handle(...$args) : ($args ? null : $phloName);
if ($handle === true){
if (isset($list[$phloName])) return $list[$phloName]->objImport(...$args);
$handle = $phloName;
}
elseif ($handle && isset($list[$handle])) return $list[$handle];
$object = new $class(...$args);
if ($handle) $list[$handle] = $object;
if ($object->hasMethod('controller') && (!phlo('req')->cli || $phloName !== 'app')) $object->controller();
return $object;function
phlo_app(...$args):void
/phlo/phlo.php line 38
if ($args['trace'] ??= false) require_once __DIR__.'/classes/trace.php';
require_once __DIR__.'/functions'.($args['trace'] ? '.trace.php' : '.php');
require_once __DIR__.'/classes/obj.php';
require_once __DIR__.'/classes/req.php';
require_once __DIR__.'/classes/res.php';
$args['app'] ?? error('No "app" path defined');
$args['debug'] ??= false;
$args['build'] ??= false;
$args['host'] ??= null;
$args['control'] ??= ($args['build'] && $args['debug']) ? 'phlo' : false;
$args['auth'] ??= false;
$args['data'] ??= $args['app'].'data/';
$args['php'] ??= $args['app'].'php/';
$args['www'] ??= $args['app'].'www/';
$args['cli'] ??= ZEND_THREAD_SAFE ? 'php-zts' : 'php';
$args['thread'] ??= false;
$args['build'] && $args['thread'] && error('Phlo build and thread mode cannot be combined');
$args['build'] && !is_file($args['data'].'app.json') && error('Phlo build mode requires data/app.json');
$args['auth'] && !$args['build'] && error('Auth requires build mode');
foreach ($args as $key => $value) define($key, $value);
define('engine', __DIR__.slash);
if ($args['debug']) require_once __DIR__.'/debug.php';
if ($args['build']) require_once __DIR__.'/classes/changed.php';
if ($args['trace']) trace::boot($args['app']);
set_error_handler(static function(int $level, string $msg, string $file = '', int $line = 0):bool {
if (!(error_reporting() & $level)) return false;
throw new ErrorException($msg, 0, $level, $file, $line);
});
set_exception_handler('phlo_exception');
spl_autoload_register(static function(string $class):void {
static $map = null, $mtime = null;
$file = php.'classmap.php';
if ($map === null || $mtime !== (is_file($file) ? filemtime($file) : null)){
$map = is_file($file) ? require $file : [];
$mtime = is_file($file) ? filemtime($file) : null;
}
if (isset($map[$class])){ require_once php.$map[$class]; return; }
});
if ($args['build']){
$engineMap = ['build' => 'build', 'reflect' => 'reflect', 'build_file' => 'file', 'build_node' => 'node', 'build_builder' => 'builder', 'build_css' => 'css', 'build_icons' => 'icons'];
spl_autoload_register(static function(string $class) use ($engineMap):void {
$name = $engineMap[strtolower($class)] ?? null;
if ($name !== null) require_once engine.'classes/'.$name.'.php';
});
}
defined('composer') && spl_autoload_register(static function(string $class):void {
static $loaded = false;
if ($loaded) return;
$loaded = true;
require_once composer.'vendor/autoload.php';
foreach (spl_autoload_functions() as $fn){
if (is_array($fn) && ($fn[0] ?? null) instanceof \Composer\Autoload\ClassLoader){
spl_autoload_unregister($fn);
spl_autoload_register($fn);
$fn[0]->loadClass($class);
return;
}
}
});
if ($args['thread'] !== false && PHP_SAPI !== 'cli'){
ignore_user_abort(true);
$handle = static function():void { phlo_thread(); };
for ($i = 1; !$args['thread'] || $i <= $args['thread']; ++$i){
$keepRunning = frankenphp_handle_request($handle);
phlo('tech/reset');
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
gc_collect_cycles();
if (!$keepRunning) break;
}
return;
}
phlo_thread();function
phlo_async(string $cb, ...$args)
/phlo/resources/phlo.async.phlo line 8
last($cmd = cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void).' > /dev/null 2>&1 & echo $!', exec($cmd, $r), isset($r[0]) && ctype_digit($r[0]) && (int)$r[0] > 0)function
phlo_cli(array $args):void
/phlo/phlo.php line 174
if (!$args) return;
$target = array_shift($args);
if (str_contains($target, dot)){
[$object, $method] = explode(dot, $target, 2);
$handle = phlo($object);
$result = $args ? $handle->$method(...$args) : ($handle->hasMethod($method) ? $handle->$method() : $handle->$method);
}
elseif (str_contains($target, '::')){
[$class, $method] = explode('::', $target, 2);
$result = $class::$method(...$args);
}
else $result = $target(...$args);
if (isset($result)) print(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf);function
phlo_exception(Throwable $e):void
/phlo/phlo.php line 33
require_once engine.'error.php';
phlo_error_handle($e);function
phlo_exists(string $obj)
/phlo/resources/phlo.exists.phlo line 7
is_file(php.strtr($obj, [us => dot]).'.php')function
phlo_load(bool $http):void
/phlo/phlo.php line 151
static $loaded = false, $loadedApp = null;
if ($loaded && $loadedApp === app){
if ($http && !phlo('res')->type) phlo('res')->type = 'text/html; charset=UTF-8';
return;
}
if (build && (!is_file(php.'functions.php') || !is_file(php.'app.php') || build_base::changed())){
debug('Builder started');
$changed = build::run();
$changed && debug('Built '.implode(', ', array_map('basename', $changed)).' ('.count($changed).')');
}
if (!is_file(php.'functions.php') || !is_file(php.'app.php')) error('Compiled runtime not available');
if (!$loaded){
require_once php.'functions.php';
$loaded = true;
}
if ($loadedApp !== app){
require_once php.'app.php';
$loadedApp = app;
}
if ($http && !phlo('res')->type) phlo('res')->type = 'text/html; charset=UTF-8';function
phlo_stream(string $cb, ...$args)
/phlo/resources/phlo.stream.phlo line 10
yield from exec_stream(cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void))function
phlo_sync(string $cb, ...$args)
/phlo/resources/phlo.sync.phlo line 8
$cmd = cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void)
exec($cmd.' 2>&1', $r, $code)
$out = implode(lf, $r)
$j = json_decode($out, true)
if ($code !== 0) error('Could not execute "'.esc($cb).'" via CLI')
if (json_last_error() !== JSON_ERROR_NONE) return $out
if (is_array($j) && isset($j['error'])) error((string)$j['error'])
return $jfunction
phlo_thread():void
/phlo/phlo.php line 113
try {
$req = phlo('req');
if ($req->cli){
$target = $req->args[0] ?? void;
if (str_starts_with($target, 'build::') || str_starts_with($target, 'reflect::')){
phlo_cli($req->args);
return;
}
phlo_load(false);
phlo('app');
phlo_cli($req->args);
return;
}
$isDashboard = build && debug && control && str_starts_with($req->path.slash, control.slash);
if (auth && !$isDashboard){
phlo_auth('site', 'Phlo App - '.host);
if (phlo('res')->done) return;
}
if ($isDashboard){
require_once engine.'control.php';
phlo_dashboard::handle(substr($req->path, strlen(control) + 1));
phlo('res')->render();
return;
}
phlo_load(true);
phlo('app');
phlo('res')->render();
}
catch (RuntimeException $e){
if ($e->getMessage() === 'PhloDump' || $e->getMessage() === 'PhloStop') return;
phlo_exception($e);
}
catch (Throwable $e){
phlo_exception($e);
}function
select(...$args):string
/phlo/resources/tags.form.phlo line 12
tag('select', ...$args)function
slug(string $text)
/phlo/resources/slug.phlo line 7
trim(preg_replace('/[^a-z0-9]+/', dash, strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text))), dash)function
tag(string $tagName, ?string $inner = null, ...$args)
/phlo/resources/tag.phlo line 8
"<$tagName".loop(array_filter($args, fn($value) => !is_null($value)), fn($value, $key) => space.strtr($key, [us => dash]).($value === true ? void : '="'.esc($value).'"'), void).'>'.(is_null($inner) ? void : "$inner</$tagName>")function
textarea(...$args):string
/phlo/resources/tags.form.phlo line 13
tag('textarea', ...$args)function
time_human(?int $time = null)
/phlo/resources/time.human.phlo line 7
static $labels
$labels ??= last($labels = arr(seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1), defined('tsLabels') && $labels = array_combine(tsLabels, $labels), $labels)
$age = time() - $time
foreach ($labels AS $range => $multiplier){
if ($age / $multiplier < 1.6583) break
$age /= $multiplier
}
return round($age)." $range"function
wsCast($wsTarget = 'all', $wsHost = host, $wsPort = websocket, ...$data)
/phlo/resources/wsCast.phlo line 8
HTTP (
'http://127.0.0.1:'.$wsPort.'/message',
JSON: true,
POST: arr (
host: $wsHost,
target: $wsTarget,
data: $data,
),
)AI
object
%AI
/phlo/resources/AI/AI.phlo
const
AI :: engines
line 10
['claude' => 'Claude', 'gpt' => 'OpenAI', 'chatgpt' => 'OpenAI', 'o1' => 'OpenAI', 'o3' => 'OpenAI', 'o4' => 'OpenAI', 'deepseek' => 'DeepSeek', 'gemini' => 'Gemini']static
AI :: sseDecodeEvents (string &$buffer, string $chunk):array
line 11
$buffer .= strtr($chunk, ["\r\n" => lf, "\r" => lf])
$events = []
while (($pos = strpos($buffer, lf.lf)) !== false){
$raw = substr($buffer, 0, $pos)
$buffer = substr($buffer, $pos + 2)
$dataLines = []
foreach (explode(lf, $raw) AS $line){
$line = ltrim($line)
if (!str_starts_with($line, 'data:')) continue
$dataLines[] = ltrim(substr($line, 5))
}
if (!$dataLines) continue
$payload = trim(implode(lf, $dataLines))
if ($payload === '[DONE]' || $payload === void) continue
$decoded = json_decode($payload)
$decoded && $events[] = $decoded
}
return $eventsstatic
AI :: sseStreamJSONWithTool (string $url, array $headers, array $payload, \Closure $extractText, \Closure $extractTool, ?\Closure $cb = null, ?\Closure $toolCb = null):obj
line 31
$req = %req
if (!$cb){
$req->cli || %res->header('Content-Type', 'text/event-stream')
$cb = fn($text) => last(print($text), $req->cli || [@ob_flush(), flush()], $text)
}
$answer = void
$toolJson = void
$buffer = void
$curl = curl_init($url)
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST')
curl_setopt($curl, CURLOPT_POSTFIELDS, $json)
curl_setopt($curl, CURLOPT_HTTPHEADER, [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)])
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5)
curl_setopt($curl, CURLOPT_WRITEFUNCTION, function($curl, $data) use ($extractText, $extractTool, $cb, $toolCb, &$buffer, &$answer, &$toolJson){
foreach (static::sseDecodeEvents($buffer, $data) AS $event){
$text = $extractText($event)
(is_null($text) || $text === void || $text === false) || $answer .= $cb((string)$text, $event)
$tool = $extractTool($event)
if (!(is_null($tool) || $tool === void || $tool === false)){
$toolJson .= (string)$tool
if ($toolCb) $toolCb((string)$tool)
}
}
return strlen($data)
})
$res = curl_exec($curl)
$res === false && error('HTTP error: '.curl_error($curl))
return new obj(answer: $answer, tool_json: $toolJson)static
AI :: http (string $url, array $headers, bool $json = true, mixed $post = null):string
line 62
$curl = curl_init($url)
if ($post !== null){
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST')
if ($json && !is_string($post)) $post = json_encode($post, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
if ($json) $headers[] = 'Content-Type: application/json'
curl_setopt($curl, CURLOPT_POSTFIELDS, $post)
}
curl_setopt_array($curl, [CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 300, CURLOPT_ENCODING => ''])
$res = curl_exec($curl)
if ($res === false) throw new \RuntimeException('HTTP error: '.curl_error($curl))
return $resstatic
AI :: resolve (...$args):array
line 75
$via = $args['via'] ?? void
unset($args['via'])
$args['model'] ??= %app->model ?? void
if ($via) $via = static::engines[strtolower($via)] ?? $via
elseif (isset($args['model'])) $via = static::engines[strtolower(explode('-', $args['model'])[0])] ?? void
return [$via ?: 'OpenAI', $args]method
%AI -> chat (...$args)
line 83
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->chat(...$args)method
%AI -> stream (...$args):Generator
line 87
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->stream(...$args)method
%AI -> stream_with_tool (...$args):obj
line 91
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->stream_with_tool(...$args)method
%AI -> embedding (...$args)
line 95
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->embedding(...$args)method
%AI -> vision (...$args)
line 99
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->vision(...$args)method
%AI -> transcribe (...$args)
line 103
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->transcribe(...$args)object
%Claude
/phlo/resources/AI/Claude.phlo
const
Claude :: model
line 10
'claude-sonnet-4-5-20250929'static
Claude :: context (...$args)
line 12
$args['messages'] ??= []
if (isset($args['system'])){
$args['system'] = [['type' => 'text', 'text' => $args['system']]]
}
if (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])
return $argsstatic
Claude :: tool ($tool):array
line 22
[
'name' => $tool->name,
'description' => $tool->desc,
'input_schema' => [
'type' => 'object',
'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'description']), ARRAY_FILTER_USE_KEY)),
'required' => array_keys($tool->args),
],
]method
%Claude -> embedding ($input, $model = 'text-embedding-3-small')
line 32
%OpenAI->embedding($input, $model)method
%Claude -> vision ($text, $image, $stream = false, ...$args)
line 34
$data = is_string($image) && str_starts_with($image, 'http') ? file_get_contents($image) : $image
$messages = [['role' => 'user', 'content' => [['type' => 'text', 'text' => $text], ['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => 'image/jpeg', 'data' => base64_encode($data)]]]]]
if ($stream) return $this->stream(...$args, messages: $messages)
else return $this->chat(...$args, messages: $messages)method
%Claude -> chat (...$args)
line 41
$args['model'] ??= static::model
$args['max_tokens'] ??= 4096
$token = $args['token'] ?? null
unset($args['token'])
$args = static::context(...$args)
$res = $this->request('messages', token: $token, POST: $args)
$return = new obj(answer: void, model: $res->model, finish: $res->stop_reason, tokens: ($res->usage->input_tokens ?? 0) + ($res->usage->output_tokens ?? 0), tokens_in: $res->usage->input_tokens ?? 0, tokens_out: $res->usage->output_tokens ?? 0)
$tools = []
foreach ($res->content AS $block){
if ($block->type === 'text') $return->answer = $block->text
elseif ($block->type === 'tool_use') $tools[] = new obj(name: $block->name, args: (array)$block->input)
}
if ($tools) $return->tools = $tools
return $returnmethod
%Claude -> parseSSE (string $url, array $headers, array $payload):Generator
line 58
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
$err = json_decode((string)stream_get_contents($stream))
fclose($stream)
error('Claude error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
$line = fgets($stream)
if ($line === false) break
$line = rtrim($line, "\r\n")
if (!str_starts_with($line, 'data:')) continue
$data = ltrim(substr($line, 5))
if ($data === void) continue
$p = json_decode($data)
if (!$p) continue
if (isset($p->error)) error('Claude stream error: '.$p->error->message)
if (($p->type ?? null) === 'message_delta'){
$finish = $p->delta->stop_reason ?? $finish
if (isset($p->usage)) $usage = $p->usage
}
if (($p->type ?? null) === 'content_block_delta' && ($p->delta->type ?? null) === 'text_delta' && isset($p->delta->text)) yield obj(text: $p->delta->text)
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->input_tokens, tokens_out: $usage?->output_tokens)method
%Claude -> stream (...$args):Generator
line 90
%app->streaming = true
$args['model'] ??= static::model
$args['max_tokens'] ??= 4096
$token = $args['token'] ?? null
unset($args['token'], $args['cb'])
$args = static::context(...$args)
$args['stream'] = true
if ($token) $headers = ['anthropic-version: 2023-06-01', 'anthropic-beta: oauth-2025-04-20', 'Authorization: Bearer '.$token]
else $headers = ['anthropic-version: 2023-06-01', 'x-api-key: '.%creds->Claude]
return $this->parseSSE('https://api.anthropic.com/v1/messages', $headers, $args)method
%Claude -> request ($uri, ...$args)
line 103
$token = $args['token'] ?? null
if ($token) $headers = ['anthropic-version: 2023-06-01', 'anthropic-beta: oauth-2025-04-20', 'Authorization: Bearer '.$token]
else $headers = ['anthropic-version: 2023-06-01', 'x-api-key: '.%creds->Claude]
$res = json_decode(AI::http("https://api.anthropic.com/v1/$uri", $headers, true, $args['POST'] ?? null))
if (isset($res->error)) error('Claude Request error: '.$res->error->message)
return $resobject
%DeepSeek
/phlo/resources/AI/DeepSeek.phlo
const
DeepSeek :: model
line 10
'deepseek-chat'static
DeepSeek :: context (...$args)
line 12
$args['messages'] ??= []
if (isset($args['system']) && array_unshift($args['messages'], ['role' => 'system', 'content' => $args['system']])) unset($args['system'])
if (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])
return $argsstatic
DeepSeek :: tool ($tool):array
line 20
[
'type' => 'function',
'function' => [
'name' => $tool->name,
'description' => $tool->desc,
'parameters' => [
'type' => 'object',
'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'desc']), ARRAY_FILTER_USE_KEY)),
'additionalProperties' => false,
'required' => array_keys($tool->args),
],
'strict' => true,
],
]method
%DeepSeek -> embedding ($input, $model = 'text-embedding-3-small')
line 35
%OpenAI->embedding($input, $model)method
%DeepSeek -> chat (...$args)
line 37
$args['model'] ??= static::model
$args = static::context(...$args)
$res = $this->request('chat/completions', POST: $args)
$return = new obj(answer: $res->choices[0]->message->content, model: $res->model, finish: $res->choices[0]->finish_reason, tokens: $res->usage->total_tokens, tokens_in: $res->usage->prompt_tokens, tokens_out: $res->usage->completion_tokens)
if (isset($res->choices[0]->message->tool_calls)) $return->tools = array_map(fn($tool) => new obj(name: $tool->function->name, args: json_decode($tool->function->arguments, true)), (array)$res->choices[0]->message->tool_calls)
return $returnmethod
%DeepSeek -> parseSSE (string $url, array $headers, array $payload):Generator
line 46
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
$err = json_decode((string)stream_get_contents($stream))
fclose($stream)
error('DeepSeek error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
$line = fgets($stream)
if ($line === false) break
$line = rtrim($line, "\r\n")
if (!str_starts_with($line, 'data:')) continue
$data = ltrim(substr($line, 5))
if ($data === '[DONE]' || $data === void) continue
$p = json_decode($data)
if (!$p) continue
if (isset($p->error)) error('DeepSeek stream error: '.$p->error->message)
if (isset($p->usage)) $usage = $p->usage
$text = $p->choices[0]->delta->content ?? null
if (!is_null($text)) yield obj(text: $text)
if ($p->choices[0]->finish_reason ?? null) $finish = $p->choices[0]->finish_reason
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->prompt_tokens, tokens_out: $usage?->completion_tokens)method
%DeepSeek -> stream (...$args):Generator
line 77
%app->streaming = true
$args['model'] ??= static::model
unset($args['cb'])
$args = static::context(...$args)
$args['stream'] = true
return $this->parseSSE('https://api.deepseek.com/v1/chat/completions', ['Authorization: Bearer '.%creds->DeepSeek], $args)method
%DeepSeek -> request ($uri, $JSON = true, ...$args)
line 86
$res = json_decode(AI::http("https://api.deepseek.com/v1/$uri", ['Authorization: Bearer '.%creds->DeepSeek], $JSON, $args['POST'] ?? null))
if (isset($res->error)) error('DeepSeek Request error: '.$res->error->message)
return $resobject
%Gemini
/phlo/resources/AI/Gemini.phlo
const
Gemini :: model
line 10
'gemini-2.0-flash'const
Gemini :: endpoint
line 11
'https://generativelanguage.googleapis.com/v1beta/models/'static
Gemini :: context (...$args)
line 13
$args['contents'] ??= []
if (isset($args['system'])){
$args['systemInstruction'] = ['parts' => [['text' => $args['system']]]]
unset($args['system'])
}
if (isset($args['assistant']) && array_push($args['contents'], ['role' => 'model', 'parts' => [['text' => $args['assistant']]]])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['contents'], ['role' => 'user', 'parts' => [['text' => $args['user']]]])) unset($args['user'])
return $argsstatic
Gemini :: tool ($tool):array
line 24
[
'name' => $tool->name,
'description' => $tool->desc,
'parameters' => [
'type' => 'object',
'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'description']), ARRAY_FILTER_USE_KEY)),
'required' => array_keys($tool->args),
],
]method
%Gemini -> embedding ($input, $model = 'text-embedding-004')
line 34
$this->request($model.':embedContent', POST: ['content' => ['parts' => [['text' => $input]]]])->embedding->valuesmethod
%Gemini -> vision ($text, $image, $stream = false, ...$args)
line 36
$model = $args['model'] ?? static::model
unset($args['model'])
$contents = [['role' => 'user', 'parts' => [['text' => $text], ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => base64_encode(is_string($image) && str_starts_with($image, 'http') ? file_get_contents($image) : $image)]]]]]
if ($stream) return $this->stream(...$args, model: $model, contents: $contents)
else return $this->chat(...$args, model: $model, contents: $contents)method
%Gemini -> chat (...$args)
line 44
$model = $args['model'] ?? static::model
unset($args['model'])
$args = static::context(...$args)
$res = $this->request($model.':generateContent', POST: $args)
$return = new obj(answer: void, model: $model, finish: $res->candidates[0]->finishReason ?? void, tokens: ($res->usageMetadata->promptTokenCount ?? 0) + ($res->usageMetadata->candidatesTokenCount ?? 0), tokens_in: $res->usageMetadata->promptTokenCount ?? 0, tokens_out: $res->usageMetadata->candidatesTokenCount ?? 0)
$tools = []
foreach ($res->candidates[0]->content->parts ?? [] AS $part){
if (isset($part->text)) $return->answer = $part->text
elseif (isset($part->functionCall)) $tools[] = new obj(name: $part->functionCall->name, args: (array)$part->functionCall->args)
}
if ($tools) $return->tools = $tools
return $returnmethod
%Gemini -> parseSSE (string $url, array $headers, array $payload):Generator
line 59
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
$err = json_decode((string)stream_get_contents($stream))
fclose($stream)
error('Gemini error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
$line = fgets($stream)
if ($line === false) break
$line = rtrim($line, "\r\n")
if (!str_starts_with($line, 'data:')) continue
$data = ltrim(substr($line, 5))
if ($data === void) continue
$p = json_decode($data)
if (!$p) continue
if (isset($p->error)) error('Gemini stream error: '.$p->error->message)
if (isset($p->usageMetadata)) $usage = $p->usageMetadata
if ($p->candidates[0]->finishReason ?? null) $finish = $p->candidates[0]->finishReason
$text = $p->candidates[0]->content->parts[0]->text ?? null
if (!is_null($text)) yield obj(text: $text)
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->promptTokenCount, tokens_out: $usage?->candidatesTokenCount)method
%Gemini -> stream (...$args):Generator
line 90
%app->streaming = true
$model = $args['model'] ?? static::model
unset($args['model'], $args['cb'])
$args = static::context(...$args)
return $this->parseSSE(static::endpoint.$model.':streamGenerateContent?alt=sse', ['x-goog-api-key: '.%creds->Gemini], $args)method
%Gemini -> request ($path, ...$args)
line 98
$res = json_decode(AI::http(static::endpoint.$path, ['x-goog-api-key: '.%creds->Gemini], true, $args['POST'] ?? null))
if (isset($res->error)) error('Gemini Request error: '.$res->error->message)
return $resobject
%OpenAI
/phlo/resources/AI/OpenAI.phlo
const
OpenAI :: model
line 10
'gpt-4o-mini'const
OpenAI :: voices
line 11
['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']static
OpenAI :: context (...$args):array
line 12
$args['messages'] ??= []
if (isset($args['system']) && array_unshift($args['messages'], ['role' => 'system', 'content' => $args['system']])) unset($args['system'])
if (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])
return $argsstatic
OpenAI :: tool ($tool):array
line 19
[
'type' => 'function',
'function' => [
'name' => $tool->name,
'description' => $tool->desc,
'parameters' => [
'type' => 'object',
'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'desc']), ARRAY_FILTER_USE_KEY)),
'additionalProperties' => false,
'required' => array_keys($tool->args),
],
'strict' => true,
],
]method
%OpenAI -> chat (...$args):obj
line 33
$args['model'] ??= static::model
$token = $args['token'] ?? null
unset($args['token'])
$args = static::context(...$args)
$res = $this->request('chat/completions', token: $token, POST: $args)
$return = new obj(answer: $res->choices[0]->message->content, model: $res->model, finish: $res->choices[0]->finish_reason, tokens: $res->usage->total_tokens, tokens_in: $res->usage->prompt_tokens, tokens_out: $res->usage->completion_tokens)
if (isset($res->choices[0]->message->tool_calls)) $return->tools = array_map(fn($tool) => new obj(name: $tool->function->name, args: json_decode($tool->function->arguments, true)), (array)$res->choices[0]->message->tool_calls)
return $returnmethod
%OpenAI -> embedding ($input, $model = 'text-embedding-3-small')
line 43
$this->request('embeddings', POST: ['input' => $input, 'model' => $model])->data[0]->embeddingmethod
%OpenAI -> parseSSE (string $url, array $headers, array $payload):Generator
line 44
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
$err = json_decode((string)stream_get_contents($stream))
fclose($stream)
error('OpenAI error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
$line = fgets($stream)
if ($line === false) break
$line = rtrim($line, "\r\n")
if (!str_starts_with($line, 'data:')) continue
$data = ltrim(substr($line, 5))
if ($data === '[DONE]' || $data === void) continue
$p = json_decode($data)
if (!$p) continue
if (isset($p->error)) error('OpenAI stream error: '.$p->error->message)
if (isset($p->usage)) $usage = $p->usage
$text = $p->choices[0]->delta->content ?? null
if (!is_null($text)) yield obj(text: $text)
if ($p->choices[0]->finish_reason ?? null) $finish = $p->choices[0]->finish_reason
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->prompt_tokens, tokens_out: $usage?->completion_tokens)method
%OpenAI -> stream (...$args):Generator
line 75
$args['model'] ??= static::model
$token = $args['token'] ?? null
unset($args['token'], $args['cb'])
$args = static::context(...$args)
$args['stream'] = true
$bearer = $token ?? %creds->OpenAI
return $this->parseSSE('https://api.openai.com/v1/chat/completions', ['Authorization: Bearer '.$bearer], $args)method
%OpenAI -> transcribe ($file, $model = 'whisper-1', ...$args):obj
line 84
if (is_string($file)) $file = new CURLFile($file)
elseif (is_a($file, 'file')) $file = $file->curl
$res = $this->request('audio/transcriptions', false, POST: [...$args, 'model' => $model, 'file' => $file, 'response_format' => 'verbose_json'])
return obj (
model: $model,
duration: $res->duration,
lang: $res->language,
text: $res->text,
)method
%OpenAI -> vision ($text, $image, $stream = false, ...$args):obj
line 95
$args['model'] ??= static::model
$messages = [['role' => 'user', 'content' => [['type' => 'text', 'text' => $text], ['type' => 'image_url', 'image_url' => ['url' => $image]]]]]
if ($stream) return $this->stream(...$args, messages: $messages)
else return $this->chat(...$args, messages: $messages)method
%OpenAI -> request ($uri, $JSON = true, $token = null, ...$args)
line 101
$bearer = $token ?? %creds->OpenAI
$res = json_decode(AI::http("https://api.openai.com/v1/$uri", ['Authorization: Bearer '.$bearer], $JSON, $args['POST'] ?? null))
if (isset($res->error)) error('OpenAI Request error: '.$res->error->message)
return $resFunctions
function
answer($question, ...$options)
/phlo/resources/AI/answer.phlo line 10
$prompt = 'You are an AI answer machine. '
$prompt .= 'You give short, direct answers without repeating the subject. '
$prompt .= 'You add no explanation, no extra text, and no quotation marks. '
$prompt .= 'Always answer in the same language as the question.'.lf
if ($options){
$prompt .= 'The user asks a question. You choose exactly one of the options below as the answer. '
$prompt .= 'Your answer matches exactly one of the options, with no extra words or punctuation. '
$prompt .= 'If none of the options apply, answer with "-".'.lf.lf
$prompt .= 'Question:'.lf.$question.lf.lf
$prompt .= 'Options:'.lf
$prompt .= implode(lf, $options)
}
else {
$prompt .= 'Give a short and precise answer to the question. '
$prompt .= 'No introduction, no explanation, no list, and no final period.'.lf.lf
$prompt .= 'Question:'.lf.$question
}
$answer = %OpenAI->chat(user: $prompt, temperature: .1)->answer
return $answer === dash ? null : $answerDB
object
%DB
/phlo/resources/DB/DB.phlo
prop
%DB -> PDO
line 11
error('No PDO connector defined')prop
%DB -> fieldQuotes
line 12
btmethod
%DB -> load (string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args)
line 14
$table = strpos($table, space) || strpos($table, dot) ? $table : "$this->fieldQuotes$table$this->fieldQuotes"
!$where && $args && $where = loop(array_keys($args), fn($column) => "$table.$column=?", ' AND ')
$joins && $joins = " $joins"
$where && $where = " WHERE $where"
$group && $group = " GROUP BY $group"
$order && $order = " ORDER BY $order"
$limit && $limit = " LIMIT $limit"
$query = "SELECT $columns FROM $table$joins$where$group$order$limit"
return $this->query($query, ...array_values($args))method
%DB -> query ($query, ...$args)
line 26
try {
if (!$args) $stmt = $this->PDO->query($query)
else {
$stmt = $this->PDO->prepare($query)
$stmt->execute($args)
}
if (debug){
$match = regex('/\b(UPDATE|INSERT INTO|DELETE FROM|FROM)\b\s+([`"\[]?\w+[`"\]]?)/i', strtr($query, [$this->fieldQuotes => void]))
$where = strtr(regex('/\bWHERE (\b.+)/is', $query)[1] ?? void, [' ORDER BY' => void])
$match && debug("Q: $match[1] $match[2]".strtr(rtrim(" $where "), [dq => void])." (".$stmt->rowCount().")")
}
}
catch (\PDOException $e){
error('Database error'.colon.lf.$query.lf.lf.$e->getMessage())
}
return $stmtmethod
%DB -> column (...$args)
line 45
$this->load(...$args)->fetchAll(\PDO::FETCH_COLUMN)method
%DB -> item (...$args)
line 46
$this->load(...$args)->fetch(\PDO::FETCH_COLUMN) ?: nullmethod
%DB -> pair (...$args)
line 47
$this->load(...$args)->fetchAll(\PDO::FETCH_KEY_PAIR)method
%DB -> group (...$args)
line 48
$this->load(...$args)->fetchAll(\PDO::FETCH_GROUP|\PDO::FETCH_CLASS, obj::class)method
%DB -> records (...$args)
line 49
$this->load(...$args)->fetchAll(\PDO::FETCH_CLASS|\PDO::FETCH_UNIQUE, obj::class)method
%DB -> rows (...$args)
line 50
$this->load(...$args)->fetchAll(\PDO::FETCH_CLASS, obj::class)method
%DB -> record (...$args)
line 51
$this->load(...$args)->fetchObject(obj::class) ?: nullmethod
%DB -> create (string $table, ...$data)
line 53
if ($ignore = $data['ignore'] ?? false) unset($data['ignore'])
$columns = $this->fieldQuotes.implode($this->fieldQuotes.comma.$this->fieldQuotes, array_keys($data)).$this->fieldQuotes
$values = implode(comma, array_fill(0, count($data), qm))
$query = "INSERT".($ignore ? ' IGNORE' : void)." INTO $table ($columns) VALUES ($values)"
$this->query($query, ...array_values(loop($data, fn($value) => is_a($value, obj::class) ? $value->id : $value)))
return $this->PDO->lastInsertId() ?: ($data['id'] ?? null)method
%DB -> change (string $table, string $where, ...$data)
line 62
$whereCount = substr_count($where, qm)
$updates = isset($data['updates']) ? $data['updates'] : void
unset($data['updates'])
$updates .= (($wheres = array_slice(array_keys($data), $whereCount)) && $updates ? comma : void).loop($wheres, fn($key) => $key.'=?', comma)
$query = "UPDATE $table SET $updates WHERE $where"
$args = array_values([...array_slice($data, $whereCount), ...array_slice($data, 0, $whereCount)])
return $this->query($query, ...$args)->rowCount()method
%DB -> delete (string $table, string $where, ...$args)
line 72
$this->query("DELETE FROM $table WHERE $where", ...$args)->rowCount()method
%DB -> begin
line 73
$this->PDO->beginTransaction()method
%DB -> commit
line 74
$this->PDO->commit()method
%DB -> rollback
line 75
$this->PDO->inTransaction() && $this->PDO->rollBack()method
%DB -> transaction ($callback)
line 77
$this->begin
try {
$result = $callback()
$this->commit
return $result
} catch (\Throwable $e){
$this->rollback
throw $e
}object
%JSONDB
/phlo/resources/DB/JSONDB.phlo
static
JSONDB :: __handle
line 11
"JSONDB/$file"method
%JSONDB -> __construct (private string $file)
line 12
$dir = dirname($this->file)
is_dir($dir) || mkdir($dir, 0755, true) || error("JSONDB: cannot create dir $dir")prop
%JSONDB -> PDO
line 16
error('JSONDB driver does not use PDO')prop
%JSONDB -> fieldQuotes
line 17
''prop
%JSONDB -> lastInsertedId
line 18
nullmethod
%JSONDB -> objRead
line 20
file_exists($this->file) ? json_decode(file_get_contents($this->file), true) ?: [] : []method
%JSONDB -> objWrite (array $data)
line 21
file_put_contents($this->file, json_encode(array_values($data), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX)method
%JSONDB -> objNextId (array $data)
line 22
$data ? (int)max(array_column($data, 'id')) + 1 : 1method
%JSONDB -> objFilter (array $data, string $where = void, ...$args)
line 24
if (!$where) return $data
$filtered = []
foreach ($data AS $row){
$match = true
$parts = preg_split('/\s+AND\s+/i', $where)
$argIndex = 0
foreach ($parts AS $part){
if (preg_match('/^[`"]?(\w+)[`"]?\s*=\s*\?$/', trim($part), $m)){
$column = $m[1]
$value = $args[$argIndex++] ?? null
if (($row[$column] ?? null) != $value) $match = false
}
elseif (preg_match('/^[`"]?(\w+)[`"]?\s+IN\s*\((.+)\)$/i', trim($part), $m)){
$column = $m[1]
$ids = array_map(fn($v) => trim($v, "\"' "), explode(',', $m[2]))
if (!in_array($row[$column] ?? null, $ids)) $match = false
}
}
$match && $filtered[] = $row
}
return $filteredmethod
%JSONDB -> objSelect (string $where = void, string $limit = void, string $order = void, ...$args)
line 48
$data = $this->objFilter($this->objRead(), $where, ...array_values($args))
if ($order){
$desc = str_contains($order, 'DESC')
$col = trim(preg_replace('/\s+(ASC|DESC)/i', '', $order), '` ')
usort($data, fn($a, $b) => $desc ? ($b[$col] ?? 0) <=> ($a[$col] ?? 0) : ($a[$col] ?? 0) <=> ($b[$col] ?? 0))
}
$limit && $data = array_slice($data, 0, (int)$limit)
return $datamethod
%JSONDB -> create (string $table, ...$data)
line 59
if ($ignore = $data['ignore'] ?? false) unset($data['ignore'])
$all = $this->objRead()
$data['id'] ??= $this->objNextId($all)
foreach ($data AS $key => $value) is_a($value, 'obj') && $data[$key] = $value->id
if ($ignore){
foreach ($all AS $row) if (($row['id'] ?? null) == $data['id']) return $data['id']
}
$all[] = $data
$this->objWrite($all)
$this->lastInsertedId = $data['id']
return $data['id']method
%JSONDB -> change (string $table, string $where, ...$data)
line 73
$all = $this->objRead()
$whereCount = substr_count($where, '?')
$whereArgs = array_slice(array_values($data), 0, $whereCount)
$updates = array_slice($data, $whereCount, null, true)
$changed = 0
foreach ($all AS &$row){
if ($this->objFilter([$row], $where, ...$whereArgs)){
foreach ($updates AS $key => $value) $row[$key] = $value
$changed++
}
}
unset($row)
$this->objWrite($all)
return $changedmethod
%JSONDB -> delete (string $table, string $where, ...$args)
line 90
$all = $this->objRead()
$matching = $this->objFilter($all, $where, ...$args)
$matchIds = array_column($matching, 'id')
$remaining = array_values(array_filter($all, fn($row) => !in_array($row['id'] ?? null, $matchIds)))
$this->objWrite($remaining)
return count($matching)method
%JSONDB -> load (string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args)
line 99
!$where && $args && $where = loop(array_keys($args), fn($column) => "$column=?", ' AND ')
$data = $this->objSelect($where, $limit, $order, ...array_values($args))
return %JSON_result($data)method
%JSONDB -> query ($query, ...$args)
line 104
error('JSONDB driver does not support raw SQL queries')method
%JSONDB -> begin
line 106
nullmethod
%JSONDB -> commit
line 107
nullmethod
%JSONDB -> rollback
line 108
nullobject
%JSON_result
/phlo/resources/DB/JSON.result.phlo
static
JSON_result :: __handle
line 9
nullprop
%JSON_result -> data
line 10
[]method
%JSON_result -> __construct (array $data)
line 11
$this->data = $datamethod
%JSON_result -> fetchAll ($mode = 2)
line 13
if ($mode === \PDO::FETCH_COLUMN) return loop($this->data, fn($row) => reset((array)$row))
if ($mode === \PDO::FETCH_KEY_PAIR){
$out = []
foreach ($this->data AS $row){
$vals = array_values((array)$row)
$out[$vals[0] ?? null] = $vals[1] ?? null
}
return $out
}
if (($mode & (\PDO::FETCH_CLASS | \PDO::FETCH_UNIQUE)) === (\PDO::FETCH_CLASS | \PDO::FETCH_UNIQUE)){
$out = []
foreach ($this->data AS $row){
$o = new obj
foreach ((array)$row AS $k => $v) $o->$k = $v
$out[$row['id'] ?? count($out)] = $o
}
return $out
}
if (($mode & \PDO::FETCH_CLASS) === \PDO::FETCH_CLASS){
$out = []
foreach ($this->data AS $row){
$o = new obj
foreach ((array)$row AS $k => $v) $o->$k = $v
$out[] = $o
}
return $out
}
return $this->datamethod
%JSON_result -> fetchObject ($class = 'obj')
line 44
if (!$this->data) return null
$row = reset($this->data)
$o = new $class
foreach ((array)$row AS $k => $v) $o->$k = $v
return $omethod
%JSON_result -> fetch ($mode = 2)
line 52
if (!$this->data) return null
$row = reset($this->data)
if ($mode === \PDO::FETCH_COLUMN) return reset((array)$row)
return $rowmethod
%JSON_result -> fetchColumn ($col = 0)
line 59
if (!$this->data) return false
$row = reset($this->data)
$vals = array_values((array)$row)
return $vals[$col] ?? falsemethod
%JSON_result -> rowCount
line 66
count($this->data)object
%migrate
/phlo/resources/DB/migrate.phlo
static
migrate :: setup ($db = null)
line 10
$db ??= %MySQL
$db->query("CREATE TABLE IF NOT EXISTS `migrations` (`id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `batch` int unsigned NOT NULL, `applied_at` int unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY (`name`))")static
migrate :: applied ($db = null)
line 15
$db ??= %MySQL
return $db->column(table: 'migrations', columns: 'name', order: 'id ASC')static
migrate :: run (string $path, $db = null)
line 20
$db ??= %MySQL
static::setup($db)
$applied = static::applied($db)
$files = glob($path.'/*.sql')
sort($files)
$batch = $applied ? ($db->item(table: 'migrations', columns: 'MAX(batch)') + 1) : 1
$count = 0
foreach ($files AS $file){
$name = basename($file, '.sql')
if (in_array($name, $applied)) continue
$sql = file_get_contents($file)
foreach (array_filter(array_map('trim', explode(';', $sql))) AS $statement) $db->query($statement)
$db->create('migrations', name: $name, batch: $batch, applied_at: time())
$count++
}
return $countstatic
migrate :: rollback ($db = null)
line 39
$db ??= %MySQL
$batch = $db->item(table: 'migrations', columns: 'MAX(batch)')
if (!$batch) return 0
$names = $db->query('SELECT name FROM migrations WHERE batch=?', $batch)->fetchAll(\PDO::FETCH_COLUMN)
$db->delete('migrations', 'batch=?', $batch)
return count($names)object
%model
/phlo/resources/DB/model.phlo
static
model :: DB
line 11
%app->DB ?? %MySQLstatic
model :: objRecords
line 12
[]static
model :: objLoaded
line 13
[]static
model :: objCache
line 14
falsestatic
model :: objRecordLimit
line 15
10000static
model :: objSoftDelete
line 16
falsestatic
model :: objAudit
line 17
falsestatic
model :: objValidate
line 18
falsestatic
model :: objLastErrors
line 19
[]static
model :: idColumn
line 20
'id'static
model :: idType
line 21
'int'static
model :: canView
line 23
truestatic
model :: canCreate
line 24
truestatic
model :: canChange
line 25
truestatic
model :: canDelete
line 26
truestatic
model :: state
line 28
%req->model ??= obj(meta: [], records: [], loaded: [], includeDeleted: [])static
model :: columns
line 29
if (isset(static::$columns)) return static::$columns
if (!method_exists(static::class, 'schema')) return static::$table.'.*'
$state = static::state()
$key = spl_object_id(static::DB()).':'.static::DB()->fieldQuotes.':'.static::$table
return $state->meta[static::class]['columns'][$key] ??= static::_columns()static
model :: _columns
line 36
$fq = static::DB()->fieldQuotes
$list = array_merge(...array_values(array_filter(loop(static::fields(), fn($field) => loop($field->objColumns, fn($col) => static::$table."$fq.$fq".$col)))))
return $fq.implode("$fq,$fq", $list).$fqstatic
model :: fields
line 41
if (!method_exists(static::class, 'schema')) return static::$fields ?? []
$state = static::state()
return $state->meta[static::class]['fields'] ??= static::_fields()static
model :: _fields
line 46
$reserved = ['table','order','fields','columns','create','change','delete','records','record','column','item','pair','DB','objRecords','objLoaded','objCache','objState','objSave','objGet','objAudit','objValidate','objLastErrors','idColumn','idType']
$fields = loop(static::schema(), fn($field, $column) => last($field->name ??= $column, $field->type === 'parent' && $field->obj ??= $column, $field))
foreach ($reserved AS $word) isset($fields[$word]) && error("Reserved column name '$word' in ".static::class)
return $fieldsstatic
model :: field ($name)
line 52
static::fields()[$name]static
model :: create (...$args)
line 54
$class = static::class
if (static::objValidate() && !static::objRunValidation($args)) return null
$record = new $class(...$args)
method_exists(static::class, 'beforeSave') && $record->beforeSave()
method_exists(static::class, 'beforeCreate') && $record->beforeCreate()
$id = static::createRecord(...$args)
$record = static::record(id: $id)
method_exists(static::class, 'afterCreate') && $record->afterCreate()
method_exists(static::class, 'afterSave') && $record->afterSave()
static::objAudit() && %audit->log($record, 'create', [], (array)$record)
return $recordstatic
model :: objRunValidation ($data)
line 68
$errors = []
$fields = static::fields()
foreach ($data AS $column => $value){
if (!($field = $fields[$column] ?? null) || !method_exists($field, 'objValidate')) continue
if ($error = $field->objValidate($value)) $errors[$column] = $error
}
static::$objLastErrors = $errors
return empty($errors)static
model :: objErrors
line 79
static::$objLastErrors ?? []static
model :: createRecord (...$args)
line 80
static::DB()->create(static::$table, ...$args)static
model :: change ($where, ...$args)
line 81
static::DB()->change(static::$table, $where, ...$args)static
model :: delete ($where, ...$args)
line 83
if (method_exists(static::class, 'beforeDelete') || method_exists(static::class, 'afterDelete') || static::objAudit()){
$records = static::DB()->query('SELECT '.static::$table.'.* FROM '.static::$table.' WHERE '.$where, ...$args)->fetchAll(\PDO::FETCH_CLASS, static::class)
foreach ($records AS $record) method_exists(static::class, 'beforeDelete') && $record->beforeDelete()
if (static::objSoftDelete()) $result = static::DB()->change(static::$table, $where, ...array_values($args), deleted_at: time())
else $result = static::DB()->delete(static::$table, $where, ...$args)
foreach ($records AS $record){
method_exists(static::class, 'afterDelete') && $record->afterDelete()
static::objAudit() && %audit->log($record, 'delete', (array)$record, [])
}
return $result
}
if (static::objSoftDelete()) return static::DB()->change(static::$table, $where, ...array_values($args), deleted_at: time())
return static::DB()->delete(static::$table, $where, ...$args)static
model :: objLogChange ($where, ...$args)
line 99
if (!static::objAudit()) return static::change($where, ...$args)
$old = static::records(where: $where)
$result = static::DB()->change(static::$table, $where, ...$args)
foreach ($old AS $record){
$fresh = static::record(id: $record->id)
$fresh && %audit->log($fresh, 'update', (array)$record, (array)$fresh)
}
return $resultmethod
%model -> objSave
line 110
$pk = static::idColumn()
$pkValue = $this->$pk ?? $this->id ?? null
$pkValue || error('Can\'t save '.static::class.' record without '.$pk)
$isNew = !static::item([$pk => $pkValue, 'columns' => $pk])
$old = $isNew ? null : clone $this
method_exists(static::class, 'beforeSave') && $this->beforeSave($old)
if ($isNew){
method_exists(static::class, 'beforeCreate') && $this->beforeCreate()
static::createRecord(...$this)
$saved = static::record([$pk => $pkValue])
method_exists(static::class, 'afterCreate') && $saved->afterCreate()
}
else {
method_exists(static::class, 'beforeChange') && $this->beforeChange($old)
static::change($pk.'=?', $pkValue, ...$this)
$saved = static::record([$pk => $pkValue])
method_exists(static::class, 'afterChange') && $saved->afterChange($old)
static::objAudit() && %audit->log($saved, 'update', (array)$old, (array)$saved)
}
method_exists(static::class, 'afterSave') && $saved->afterSave($old)
return $savedstatic
model :: transaction ($callback)
line 134
static::DB()->transaction($callback)static
model :: query
line 135
phlo('query', class: static::class)static
model :: objIncludeDeleted
line 137
static::state()->includeDeleted[static::class] ?? falsestatic
model :: objWithDeleted ($callback)
line 138
$state = static::state()
$state->includeDeleted[static::class] = true
try {
$result = $callback()
} finally {
unset($state->includeDeleted[static::class])
}
return $resultstatic
model :: objRestore ($id)
line 148
static::DB()->change(static::$table, static::idColumn().'=?', $id, deleted_at: null)static
model :: column (...$args)
line 150
static::recordsLoad($args, 'fetchAll', [\PDO::FETCH_COLUMN])static
model :: item (...$args)
line 151
static::recordsLoad($args, 'fetch', [\PDO::FETCH_COLUMN])static
model :: pair (...$args)
line 152
static::recordsLoad($args, 'fetchAll', [\PDO::FETCH_KEY_PAIR])static
model :: records (...$args)
line 153
static::recordsLoad($args, 'fetchAll', [\PDO::FETCH_CLASS|\PDO::FETCH_UNIQUE, static::class], true)static
model :: recordCount (...$args)
line 154
static::item(...$args, columns: 'COUNT('.static::idColumn().')')static
model :: record (...$args)
line 155
count($records = static::records(...$args)) > 1 ? error('Multiple records for '.static::class) : (current($records) ?: null)static
model :: recordsLoad ($args, $fetch, $fetchMode, $saveRelations = false)
line 157
$pk = static::idColumn()
$args['table'] ??= static::$table
$saveRelations && $args['columns'] ??= static::$table.'.'.$pk.' as _,'.static::columns()
isset(static::$joins) && debug && error('DEPRECATED: static $joins in '.static::class.'. Use getParent/getChildren/getMany instead.')
isset(static::$joins) && $args['joins'] = static::$joins.(isset($args['joins']) ? " $args[joins]" : void)
method_exists(static::class, 'where') && $args['where'] = static::where().(isset($args['where']) ? " AND $args[where]" : void)
static::objSoftDelete() && !static::objIncludeDeleted() && $args['where'] = 'deleted_at IS NULL'.(isset($args['where']) ? " AND $args[where]" : void)
isset(static::$group) && $args['group'] ??= static::$group
isset(static::$order) && $args['order'] ??= static::$order
if ($cacheKey = $args['cacheKey'] ?? null) unset($args['cacheKey'])
if ($duration = $args['cache'] ?? static::objCache()){
unset($args['cache'])
$cacheArgs = $args; ksort($cacheArgs)
$records = apcu($cacheKey ?? static::class.slash.md5(json_encode($cacheArgs)), fn() => static::DB()->load(...$args)->$fetch(...$fetchMode), $duration === true ? 86400 : $duration)
}
else $records = static::DB()->load(...$args)->$fetch(...$fetchMode)
if ($saveRelations && $records){
$state = static::state()
$state->records[static::class] = ($state->records[static::class] ?? []) + array_column($records, null, $pk)
count($state->records[static::class]) > static::objRecordLimit() && $state->records[static::class] = array_slice($state->records[static::class], -static::objRecordLimit(), preserve_keys: true)
}
return $recordsstatic
model :: objRel ($key)
line 182
$state = static::state()
return $state->meta[static::class][$key] ??= method_exists(static::class, $key) ? static::$key() : static::$$key ?? []prop
%model -> objState
line 187
['parents' => [], 'children' => [], 'many' => []]method
%model -> objGet ($key)
line 188
$this->getParent($key) ?? $this->getChildren($key) ?? $this->getMany($key)method
%model -> objIn ($ids)
line 189
$ids ? dq.implode(dq.comma.dq, $ids).dq : 'NULL'method
%model -> getParent ($key)
line 191
if (array_key_exists($key, $this->objState['parents'])) return $this->objState['parents'][$key]
$state = static::state()
$parents = self::objRel('objParents')
if (!$relation = $parents[$key] ?? null) return
$isArray = is_array($relation)
$class = $isArray ? $relation['obj'] : $relation
$column = $isArray ? $relation['key'] ?? $key : $key
if (!$parentId = $this->objData[$column] ?? null) return $this->objState['parents'][$key] = null
if (!isset($state->records[$class][$parentId])){
$idsToLoad = [$parentId => true]
$allObjData = array_map(fn($record) => $record->objData, $state->records[static::class] ?? [])
foreach ($parents as $pKey => $pRelation){
$pIsArray = is_array($pRelation)
$pClass = $pIsArray ? $pRelation['obj'] : $pRelation
if ($pClass === $class) foreach (array_column($allObjData, $pIsArray ? $pRelation['key'] ?? $pKey : $pKey) as $pId) $pId && !isset($state->records[$class][$pId]) && $idsToLoad[$pId] = true
}
if ($idsToLoad = array_keys($idsToLoad)) $class::records(where: $class::idColumn().' IN ('.$this->objIn($idsToLoad).')')
}
$parentObject = $state->records[$class][$parentId] ?? null
return $this->objState['parents'][$key] = $parentObjectmethod
%model -> getChildren ($key)
line 214
if (array_key_exists($key, $this->objState['children'])) return $this->objState['children'][$key]
$state = static::state()
if (!$relation = self::objRel('objChildren')[$key] ?? null) return
$isArray = is_array($relation)
$class = $isArray ? $relation['obj'] : $relation
$column = $isArray ? $relation['key'] : static::objShortName()
if (!isset($state->loaded[static::class]['children'][$key])){
$parentIds = array_keys($state->records[static::class] ?? [])
if ($parentIds){
$fq = static::DB()->fieldQuotes
$children = $class::records(where: $fq.$column.$fq.' IN ('.$this->objIn($parentIds).')')
foreach ($state->records[static::class] AS $parentRecord) $parentRecord->objState['children'][$key] = []
foreach ($children AS $childId => $child) !is_null($pId = $child->objData[$column] ?? null) && isset($state->records[static::class][$pId]) && $state->records[static::class][$pId]->objState['children'][$key][$childId] = $child
}
$state->loaded[static::class]['children'][$key] = true
}
return $this->objState['children'][$key] ?? []method
%model -> getMany ($key)
line 234
if (array_key_exists($key, $this->objState['many'])) return $this->objState['many'][$key]
$state = static::state()
if (!$relation = self::objRel('objMany')[$key] ?? null) return
$class = $relation['obj']
if (!isset($state->loaded[static::class]['many'][$key])){
$parentIds = array_keys($state->records[static::class] ?? [])
if ($parentIds){
$fq = static::DB()->fieldQuotes
$lk = $relation['localKey']; $fk = $relation['foreignKey']
$pivotRows = static::DB()->rows(table: $relation['table'], columns: $fq.$lk.$fq.comma.$fq.$fk.$fq, where: $fq.$lk.$fq.' IN ('.$this->objIn($parentIds).')')
$targetIds = array_unique(array_map(fn($row) => $row->{$relation['foreignKey']}, $pivotRows ?: []))
$targetRecords = $targetIds ? $class::records(where: $class::idColumn().' IN ('.$this->objIn($targetIds).')') : []
foreach ($state->records[static::class] AS $parentRecord) $parentRecord->objState['many'][$key] = []
foreach ($pivotRows ?: [] AS $row){
$parentId = $row->$lk
$foreignId = $row->$fk
if (isset($state->records[static::class][$parentId]) && isset($targetRecords[$foreignId])) $state->records[static::class][$parentId]->objState['many'][$key][$foreignId] = $targetRecords[$foreignId]
}
}
$state->loaded[static::class]['many'][$key] = true
}
return $this->objState['many'][$key] ?? []method
%model -> getCount ($key)
line 259
if (array_key_exists($key, $this->objState['counts'] ?? [])) return $this->objState['counts'][$key]
$state = static::state()
if ($relation = self::objRel('objChildren')[$key] ?? null){
if (!isset($state->loaded[static::class]['children_count'][$key])){
$parentIds = array_keys($state->records[static::class] ?? [])
if ($parentIds){
$isArray = is_array($relation)
$class = $isArray ? $relation['obj'] : $relation
$column = $isArray ? $relation['key'] : static::objShortName()
$fq = static::DB()->fieldQuotes
$counts = $class::pair(columns: $fq.$column.$fq.', COUNT(*)', where: $fq.$column.$fq.' IN ('.$this->objIn($parentIds).')', group: $fq.$column.$fq)
foreach ($state->records[static::class] as $id => $record) $record->objState['counts'][$key] = (int)($counts[$id] ?? 0)
}
$state->loaded[static::class]['children_count'][$key] = true
}
return $this->objState['counts'][$key] ?? 0
}
if ($relation = self::objRel('objMany')[$key] ?? null){
if (!isset($state->loaded[static::class]['many_count'][$key])){
$parentIds = array_keys($state->records[static::class] ?? [])
if ($parentIds){
$fq = static::DB()->fieldQuotes
$localKey = $relation['localKey']
$counts = static::DB()->load(table: $relation['table'], columns: $fq.$localKey.$fq.',COUNT(*)', where: $fq.$localKey.$fq.' IN ('.$this->objIn($parentIds).')', group: $fq.$localKey.$fq)->fetchAll(\PDO::FETCH_KEY_PAIR)
foreach ($state->records[static::class] as $id => $record) $record->objState['counts'][$key] = (int)($counts[$id] ?? 0)
}
$state->loaded[static::class]['many_count'][$key] = true
}
return $this->objState['counts'][$key] ?? 0
}
return 0method
%model -> getLast ($key)
line 293
if (array_key_exists($key, $this->objState['last_child'] ?? [])) return $this->objState['last_child'][$key]
$state = static::state()
if ($relation = self::objRel('objChildren')[$key] ?? null){
if (!isset($state->loaded[static::class]['last_child'][$key])){
if ($parentIds = array_keys($state->records[static::class] ?? [])){
$isArray = is_array($relation)
$class = $isArray ? $relation['obj'] : $relation
$column = $isArray ? $relation['key'] : static::objShortName()
$childTable = $class::$table
$fq = static::DB()->fieldQuotes
$qt = $fq.$childTable.$fq
$qc = $fq.$column.$fq
$ids = $this->objIn($parentIds)
$childPk = $class::idColumn()
$joins = ' INNER JOIN (SELECT MAX('.$fq.$childPk.$fq.') AS last_id, '.$qc.' AS parent_id FROM '.$qt.' WHERE '.$qc.' IN ('.$ids.') GROUP BY '.$qc.') AS lcmax ON '.$qt.'.'.$fq.$childPk.$fq.' = lcmax.last_id'
$lastChildren = $class::records(joins: $joins)
foreach ($state->records[static::class] as $record) $record->objState['last_child'][$key] = null
foreach ($lastChildren as $child) if (isset($state->records[static::class][$parentId = $child->objData[$column]])) $state->records[static::class][$parentId]->objState['last_child'][$key] = $child
}
$state->loaded[static::class]['last_child'][$key] = true
}
return $this->objState['last_child'][$key] ?? null
}
return nullstatic
model :: objResolveClass ($name)
line 320
$namestatic
model :: objShortName ($class = null)
line 321
$class ?? static::classstatic
model :: objParents
line 323
if (property_exists(static::class, 'objParents')) return static::$objParents
if (!method_exists(static::class, 'schema')) return []
return loop(array_filter(static::fields(), fn($f) => $f->type === 'parent'), fn($f, $c) => $f->key ? arr(obj: static::objResolveClass($f->obj), key: $f->key) : (static::objResolveClass($f->obj ?? $c)))static
model :: objChildren
line 329
if (property_exists(static::class, 'objChildren')) return static::$objChildren
if (!method_exists(static::class, 'schema')) return []
return loop(array_filter(static::fields(), fn($f) => $f->type === 'child'), fn($f, $c) => $f->key ? arr(obj: static::objResolveClass($f->obj), key: $f->key) : (static::objResolveClass($f->obj ?? $c)))static
model :: objMany
line 335
if (property_exists(static::class, 'objMany')) return static::$objMany
if (!method_exists(static::class, 'schema')) return []
return loop(array_filter(static::fields(), fn($f) => $f->type === 'many'), fn($f) => arr(obj: static::objResolveClass($f->obj), table: $f->table, localKey: $f->localKey ?? static::objShortName(), foreignKey: $f->foreignKey ?? $f->obj))object
%MySQL
/phlo/resources/DB/MySQL.phlo
prop
%MySQL -> PDO
line 11
new \PDO('mysql:host='.%creds->mysql->host.';dbname='.%creds->mysql->database, %creds->mysql->user, %creds->mysql->password)prop
%MySQL -> objPers
line 12
trueobject
%PostgreSQL
/phlo/resources/DB/PostgreSQL.phlo
prop
%PostgreSQL -> PDO
line 11
new PDO('pgsql:host='.%creds->postgresql->host.';dbname='.%creds->postgresql->database, %creds->postgresql->user, %creds->postgresql->password)prop
%PostgreSQL -> fieldQuotes
line 12
dqobject
%Qdrant
/phlo/resources/DB/Qdrant.phlo
method
%Qdrant -> get (string $input, ?string $model = null)
line 10
apcu('embedding/'.token(input: $input), fn($input) => %AI->embedding(input: $input, model: $model), 86400 * 28)method
%Qdrant -> collections
line 12
array_column($this->request('collections')->result->collections, 'name')method
%Qdrant -> create ($collection, $size = 1536, $distance = 'Cosine')
line 13
$this->request("collections/$collection", PUT: arr(vectors: arr(size: $size, distance: $distance)))->status === 'ok'method
%Qdrant -> upsert ($collection, $id, $input, ...$payload)
line 14
$this->request("collections/$collection/points", PUT: arr(points: [arr(id: $id, vector: $this->get($input), payload: $payload ?: null)]))->result->operation_idmethod
%Qdrant -> delete ($collection, ...$ids)
line 15
$this->request("collections/$collection/points/delete", POST: arr(points: $ids))->resultmethod
%Qdrant -> search ($collection, $input = null, $top = 100)
line 16
create($this->request("collections/$collection/points/search", POST: arr(vector: is_null($input) ? array_fill(0, 1536, 0) : $this->get($input), top: $top, with_payload: true))->result, fn($record) => $record->id, fn($record) => last($record = array_merge(get_object_vars($record), get_object_vars($record->payload)), obj(...array_filter($record, fn($key) => $key !== 'payload', ARRAY_FILTER_USE_KEY))))method
%Qdrant -> drop ($collection)
line 17
$this->request("collections/$collection", DELETE: true)->resultmethod
%Qdrant -> request ($uri, ...$data)
line 19
json_decode(HTTP(%creds->qdrant->server.$uri, %creds->qdrant->key ? ['api-key: '.%creds->qdrant->key] : [], true, ...$data))object
%query
/phlo/resources/DB/query.phlo
prop
%query -> class
line 10
prop
%query -> conditions
line 11
[]prop
%query -> bindings
line 12
[]prop
%query -> orderBy
line 13
prop
%query -> limitVal
line 14
prop
%query -> offsetVal
line 15
method
%query -> fq
line 16
($class = $this->class) ? $class::DB()->fieldQuotes : btmethod
%query -> q ($column)
line 17
preg_match('/^[A-Za-z_][A-Za-z0-9_.]*$/', $column) || error("Invalid column for query builder: $column")
$fq = $this->fq
return implode(dot, array_map(fn($part) => $fq.$part.$fq, explode(dot, $column)))method
%query -> eq ($column, $value)
line 23
$this->where($this->q($column)." = ?", $value)method
%query -> neq ($column, $value)
line 24
$this->where($this->q($column)." != ?", $value)method
%query -> gt ($column, $value)
line 25
$this->where($this->q($column)." > ?", $value)method
%query -> gte ($column, $value)
line 26
$this->where($this->q($column)." >= ?", $value)method
%query -> lt ($column, $value)
line 27
$this->where($this->q($column)." < ?", $value)method
%query -> lte ($column, $value)
line 28
$this->where($this->q($column)." <= ?", $value)method
%query -> like ($column, $value)
line 29
$this->where($this->q($column)." LIKE ?", $value)method
%query -> in ($column, array $values)
line 30
$this->where($this->q($column)." IN (".implode(',', array_fill(0, count($values), '?')).")", ...$values)method
%query -> isNull ($column)
line 31
$this->where($this->q($column)." IS NULL")method
%query -> notNull ($column)
line 32
$this->where($this->q($column)." IS NOT NULL")method
%query -> between ($column, $min, $max)
line 33
$this->where($this->q($column)." BETWEEN ? AND ?", $min, $max)method
%query -> raw ($sql, ...$bindings)
line 34
$this->where($sql, ...$bindings)method
%query -> where ($condition, ...$values)
line 35
$this->conditions[] = $condition
foreach ($values AS $v) $this->bindings[] = $v
return $thismethod
%query -> order ($order)
line 41
$this->orderBy = $order
return $thismethod
%query -> limit ($limit)
line 46
$this->limitVal = $limit
return $thismethod
%query -> offset ($offset)
line 51
$this->offsetVal = $offset
return $thismethod
%query -> build
line 55
$where = $this->conditions ? implode(' AND ', $this->conditions) : void
$limit = $this->limitVal ? ($this->offsetVal ? "$this->offsetVal,$this->limitVal" : "$this->limitVal") : void
$args = ['where' => $where ?: void, 'order' => $this->orderBy ?: void, 'limit' => $limit ?: void]
foreach ($this->bindings AS $b) $args[] = $b
return $argsprop
%query -> records
line 63
($class = $this->class) && $class::records(...$this->build)prop
%query -> record
line 64
($class = $this->class) && $class::record(...$this->build)prop
%query -> column
line 65
($class = $this->class) && $class::column(...$this->build)prop
%query -> item
line 66
($class = $this->class) && $class::item(...$this->build)prop
%query -> count
line 67
($class = $this->class) && $class::recordCount(...$this->build)method
%query -> delete
line 68
$class = $this->class
$where = $this->conditions ? implode(' AND ', $this->conditions) : error('Cannot delete without conditions')
return $class::delete($where, ...$this->bindings)object
%SQLite
/phlo/resources/DB/SQLite.phlo
static
SQLite :: __handle
line 11
"SQLite/$file"method
%SQLite -> __construct (private string $file)
line 12
prop
%SQLite -> PDO
line 13
new PDO('sqlite:'.$this->file)DOM
object
%cookiewall
/phlo/resources/DOM/cookiewall.phlo
static
cookiewall :: __handle
line 10
nullprop
%cookiewall -> choice
line 12
%cookies->cookieChoice ?? nullmethod
%cookiewall -> hasChosen
line 13
$this->choice !== nullmethod
%cookiewall -> canTrack
line 14
$this->choice === 'all'method
%cookiewall -> canAnalytics
line 15
$this->choice === 'all'route
route async POST cookiewall accept all
line 17
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')route
route async POST cookiewall accept essential
line 22
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')view
%cookiewall -> banner
line 27
<if !$this->hasChosen()>
<div#cookiewall role=region aria-label="Cookie choice">
<p>We use essential cookies to make this site work. With your permission we also use analytics to improve the site.</p>
<div.actions>
<form.async method=post action=/cookiewall/accept/essential>
<button.ghost type=submit>Essential only</button>
</form>
<form.async method=post action=/cookiewall/accept/all>
<button.primary type=submit>Accept</button>
</form>
</div>
</div>
</if>view
style
line 42
#cookiewall {
background: #1a1a1a
border-radius: 8px
bottom: 16px
box-shadow: 0 8px 32px #0004
color: #fff
font-size: 13px
left: 16px
line-height: 1.5
max-width: 360px
padding: 14px 16px
position: fixed
right: 16px
z-index: 9999
@media(min-width: 600px): right: auto
p: margin: 0 0 10px
.actions {
display: flex
gap: 8px
justify-content: flex-end
}
button {
border-radius: 4px
border: 0
cursor: pointer
font-size: 12px
padding: 6px 12px
}
button.ghost {
background: #444
color: #fff
}
button.primary {
background: #fff
color: #1a1a1a
font-weight: 600
}
}object
%cookiewall_translated
/phlo/resources/DOM/cookiewall.translated.phlo
view
%cookiewall_translated -> banner
line 12
<if !$this->hasChosen()>
<div#cookiewall role=region aria-label="{en: Cookie choice}">
<p>{en: We use essential cookies to make this site work. With your permission we also use analytics to improve the site.}</p>
<div.actions>
<form.async method=post action=/cookiewall/accept/essential>
<button.ghost type=submit>{en: Essential only}</button>
</form>
<form.async method=post action=/cookiewall/accept/all>
<button.primary type=submit>{en: Accept}</button>
</form>
</div>
</div>
</if>object
%CSS_fixes
/phlo/resources/DOM/CSS.fixes.phlo
view
style
line 9
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
::-ms-expand: display: noneobject
%CSS_var
/phlo/resources/DOM/CSS.var.phlo
view
script
line 9
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = valueobject
%datatags
/phlo/resources/DOM/datatags.phlo
view
script
line 10
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
if (el.dataset.confirm) return
e.preventDefault()
let method, path, data = null
if ((path = el.dataset.get) !== undefined) method = 'get'
else if ((path = el.dataset.delete) !== undefined) method = 'delete'
else [data = {}, Object.keys(el.dataset).forEach((key) => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
app[method](path, data)
})object
%dialog
/phlo/resources/DOM/dialog.phlo
view
script
line 10
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)
phlo.dialog = async (type, message, defaultValue = '') => new Promise((resolve) => {
app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
const dialog = obj('#phloDialog')
const messageEl = dialog.querySelector('.phlo-dialog__message')
messageEl && (messageEl.textContent = String(message ?? ''))
if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
dialog.showModal()
dialog.addEventListener('close', () => {
const value = dialog.returnValue
const input = dialog.querySelector('input')
dialog.remove()
if (type === 'alert') return resolve()
if (type === 'confirm') return resolve(value === '1')
if (type === 'prompt') return resolve(value === '1' ? input.value : null)
})
})
on('click', '[data-confirm]', async (el, e) => {
e.preventDefault()
if (!await window.confirm(el.dataset.confirm)) return
delete el.dataset.confirm
app.update()
el.click()
})object
%exists
/phlo/resources/DOM/exists.phlo
view
script
line 10
phlo.exist = []
phlo.existing = new WeakMap
const onExist = (els, cb) => phlo.exist.push({els, cb})
app.updates.push(() => {
const existing = []
phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})object
%form
/phlo/resources/DOM/form.phlo
view
script
line 10
on('input change', 'input, select, textarea', input => {
if (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
if (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')
if (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)
if (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value
phlo.state.replace()
return false
})
on('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])object
%image_resizer
/phlo/resources/DOM/image.resizer.phlo
view
script
line 9
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
const img = new Image
img.onload = () => {
let width = img.width, height = img.height
const aspectRatio = width / height
if (width > maxWidth || height > maxHeight){
if (width > height){
width = maxWidth
height = Math.round(maxWidth / aspectRatio)
}
else {
height = maxHeight
width = Math.round(maxHeight * aspectRatio)
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
cb(canvas.toDataURL(file.type, quality))
}
img.src = URL.createObjectURL(file)
}object
%link
/phlo/resources/DOM/link.phlo
view
script
line 10
on('click', 'a', (a, e) => {
if (e.ctrlKey || e.shiftKey || e.metaKey || a.dataset.confirm) return false
const isAsync = a.classList.contains('async')
const [uri, hash] = a.getAttribute('href').split('#')
if (isAsync || hash) e.preventDefault()
phlo.anchor = hash ? `#${hash}` : ''
if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
else if (isAsync) app.get(uri.substr(1))
})object
%markdown
/phlo/resources/DOM/markdown.phlo
view
script
line 9
function parse_markdown(md, opts = {}){
const o = {
gfm: opts.gfm !== false,
breaks: !!opts.breaks,
headerIds: opts.headerIds !== false,
headerPrefix: opts.headerPrefix || '',
smartypants: !!opts.smartypants
}
const unnull = x => (x == null ? '' : String(x))
let src = unnull(md).replace(/\r\n?/g, "\n")
const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]))
const trimEndNL = s => s.replace(/\s+$/,'')
const isBlank = s => /^\s*$/.test(s)
const slugmap = new Map()
const slug = (t) => {
let s = t.toLowerCase().replace(/<\/?[^>]+>/g, '').replace(/[^\p{L}\p{N}\- _]+/gu, '').trim().replace(/[\s_]+/g, '-')
const base = o.headerPrefix + s
let k = base, i = 1
while (slugmap.has(k)) k = `${base}-${++i}`
slugmap.set(k, true)
return k
}
const smart = s => {
if (!o.smartypants) return s
return s.replace(/---/g, "-").replace(/--/g, "–").replace(/(^|[\s"(\[])(?=')/g, "$1‘").replace(/'/g, "’").replace(/(^|[\s(\[])(?=")/g, "$1“").replace(/"/g, "”").replace(/\.{3}/g, "…")
}
const refs = Object.create(null)
src = src.replace(
/^ {0,3}\[([^\]]+)\]:\s*<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm,
(_, label, url, t1, t2, t3) => {
const key = label.trim().replace(/\s+/g, ' ').toLowerCase()
if (!refs[key]) refs[key] = { href: url, title: t1 || t2 || t3 || '' }
return ''
}
)
const tokens = []
const lines = src.split("\n")
function takeWhile(start, pred){
let end = start
while (end < lines.length && pred(lines[end], end)) end++
return { start, end }
}
function pushParagraph(buf){
const text = buf.join("\n").trimEnd()
if (text) tokens.push({ type: "paragraph", text })
buf.length = 0
}
function parseBlock(start = 0, end = lines.length){
const para = []
let l = start
while (l < end){
const line = lines[l]
if (isBlank(line)){ pushParagraph(para); l++; continue; }
let m = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)$/)
if (m){
pushParagraph(para)
const fenceLen = m[1].length
const info = (m[2] || '').trim()
let body = []
l++
while (l < end){
const s = lines[l]
const close = s.match(new RegExp(`^ {0,3}${m[1][0]}{${fenceLen},}\\s*$`))
if (close){ l++; break; }
body.push(s)
l++
}
tokens.push({ type: "code", lang: info.split(/\s+/)[0] || '', text: trimEndNL(body.join("\n")) })
continue
}
if (/^(?: {4}|\t)/.test(line)){
pushParagraph(para)
const { end: j } = takeWhile(l, s => /^(?: {4}|\t)/.test(s) || isBlank(s))
const block = lines.slice(l, j).map(s => s.replace(/^(?: {4}|\t)/, '')).join("\n")
tokens.push({ type: "code", lang: '', text: trimEndNL(block) })
l = j; continue
}
if (/^ {0,3}<(?:!--|\/?(?:html|head|body|pre|script|style|table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|blockquote|ul|ol|li|section|article|aside|details|summary|figure|figcaption)\b)/i.test(line)){
pushParagraph(para)
const { end: j } = takeWhile(l, (s, idx) => !(idx > l && isBlank(lines[idx-1]) && isBlank(s)))
const html = lines.slice(l, j).join("\n")
tokens.push({ type: "html", text: html })
l = j; continue
}
if (/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(line)){
pushParagraph(para)
tokens.push({ type: "hr" })
l++; continue
}
m = line.match(/^ {0,3}(#{1,6})[ \t]*([^#\n]*?)[ \t#]*$/)
if (m){
pushParagraph(para)
tokens.push({ type: "heading", depth: m[1].length, text: m[2].trim() })
l++; continue
}
if (l + 1 < end && /^[^\s].*$/.test(line) && /^ {0,3}(=+|-+)\s*$/.test(lines[l + 1])){
pushParagraph(para)
const depth = lines[l + 1].trim().startsWith("=") ? 1 : 2
tokens.push({ type: "heading", depth, text: line.trim() })
l += 2; continue
}
if (/^ {0,3}>\s?/.test(line)){
pushParagraph(para)
const { end: j } = takeWhile(l, s => /^ {0,3}>\s?/.test(s) || isBlank(s))
const inner = lines.slice(l, j).map(s => s.replace(/^ {0,3}>\s?/, '')).join("\n")
const sub = parse_markdown(inner, { ...o })
tokens.push({ type: "blockquote", html: sub })
l = j; continue
}
m = line.match(/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/)
if (m){
pushParagraph(para)
const bulletRe = /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/
const { end: j } = takeWhile(l, (s, idx) =>
bulletRe.test(s) ||
(/^(?: {4}|\t)/.test(s)) ||
(!isBlank(s) && idx > l && !/^(?: {0,3}(?:[*+-]|\d{1,9}[.)])\s+)/.test(s))
)
const block = lines.slice(l, j)
const ordered = /^\d/.test(m[1])
const items = []
let cur = []
for (let k = 0; k < block.length; k++){
const ln = block[k]
const head = ln.match(bulletRe)
if (head){
if (cur.length) items.push(cur), cur = []
cur.push(ln.replace(bulletRe, ''))
} else {
cur.push(ln.replace(/^(?: {4}|\t)/, ''))
}
}
if (cur.length) items.push(cur)
const parsedItems = items.map(linesArr => {
let raw = linesArr.join("\n").replace(/\n\s+$/,'')
let checked = null
if (o.gfm){
const t = raw.match(/^\[([ xX])\][ \t]+/)
if (t){ checked = t[1].toLowerCase() === 'x'; raw = raw.replace(/^\[[ xX]\][ \t]+/, ''); }
}
const html = parse_markdown(raw, o)
return { html, checked }
})
tokens.push({ type: "list", ordered, items: parsedItems })
l = j; continue
}
if (o.gfm){
const hdr = line
const alignLn = lines[l + 1] || ''
if (/\|/.test(hdr) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(alignLn)){
pushParagraph(para)
const aligns = alignLn
.trim().replace(/^(\|)|(\|)$/g,'')
.split("|").map(s => s.trim()).map(s => s.startsWith(":-") && s.endsWith("-:") ? "center" : s.endsWith("-:") ? "right" : s.startsWith(":-") ? "left" : null)
const headerCells = hdr.trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim())
l += 2
const rows = []
while (l < end && /\|/.test(lines[l]) && !isBlank(lines[l])){
rows.push(lines[l].trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim()))
l++
}
tokens.push({ type: "table", header: headerCells, aligns, rows })
continue
}
}
para.push(line)
const next = lines[l + 1] || ''
const endPara =
isBlank(next) ||
/^ {0,3}(?:`{3,}|~{3,})/.test(next) ||
/^(?: {4}|\t)/.test(next) ||
/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/.test(next) ||
/^ {0,3}(#{1,6})/.test(next) ||
/^ {0,3}>\s?/.test(next) ||
/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(next) ||
(o.gfm && /\|/.test(next) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(lines[l + 2] || ''))
if (endPara) pushParagraph(para)
l++
}
pushParagraph(para)
}
parseBlock(0, lines.length)
function renderInline(s){
if (!s) return ''
s = s.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1/g, (_, ticks, code) => `<code>${escHtml(code)}</code>`)
s = s.replace(/!\[([^\]]*)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
(_, alt, url, t1, t2, t3) => `<img src="${escHtml(url)}" alt="${escHtml(alt)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>`)
s = s.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (_, alt, id) => {
const ref = refs[(id || alt).trim().replace(/\s+/g,' ').toLowerCase()]
return ref ? `<img src="${escHtml(ref.href)}" alt="${escHtml(alt)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>` : _
})
s = s.replace(/\[([^\]]+)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
(_, text, url, t1, t2, t3) => `<a href="${escHtml(url)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>${text}</a>`)
s = s.replace(/\[([^\]]+)\]\s*\[([^\]]*)\]/g, (_, text, id) => {
const key = (id || text).trim().replace(/\s+/g,' ').toLowerCase()
const ref = refs[key]
return ref ? `<a href="${escHtml(ref.href)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>${text}</a>` : _
})
s = s.replace(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^ <>"']+)>/g, (_, url) => `<a href="${escHtml(url)}">${escHtml(url)}</a>`)
s = s.replace(/<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, (_, mail) => `<a href="mailto:${escHtml(mail)}">${escHtml(mail)}</a>`)
if (o.gfm){
s = s.replace(/(?:(?<=\s)|^)(https?:\/\/[^\s<]+)(?=\s|$)/g, '<a href="$1">$1</a>')
s = s.replace(/(?:(?<=\s)|^)(www\.[^\s<]+)(?=\s|$)/g, '<a href="http://$1">$1</a>')
}
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>').replace(/__([\s\S]+?)__/g, '<strong>$1</strong>')
s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>').replace(/_([^_\n]+?)_/g, '<em>$1</em>')
if (o.gfm) s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>')
s = s.replace(/ {2,}\n/g, "<br>\n")
if (o.breaks) s = s.replace(/\n/g, "<br>\n")
s = s.replace(/&(?!#?\w+;)/g, "&").replace(/<(?!\/?[A-Za-z][^>]*>)/g, "<")
return smart(s)
}
let out = ''
for (const t of tokens){
switch (t.type){
case "paragraph":
out += `<p>${renderInline(t.text)}</p>\n`
break
case "heading": {
const text = renderInline(t.text)
const id = o.headerIds ? slug(text.replace(/<[^>]+>/g, '')) : null
out += id ? `<h${t.depth} id="${id}">${text}</h${t.depth}>\n` : `<h${t.depth}>${text}</h${t.depth}>\n`
break
}
case "code": {
const cls = t.lang ? ` class="language-${escHtml(t.lang)}"` : ''
out += `<pre><code${cls}>${escHtml(t.text)}</code></pre>\n`
break
}
case "blockquote":
out += `<blockquote>\n${t.html.trim()}\n</blockquote>\n`
break
case "list": {
const tag = t.ordered ? "ol" : "ul"
out += `<${tag}>\n`
for (const it of t.items){
const task = it.checked === null ? '' : `<input ${it.checked ? 'checked="" ' : ''}disabled="" type="checkbox"> `
const body = it.html.trim().replace(/^<p>/, task + "<p>")
out += `<li>${body}</li>\n`
}
out += `</${tag}>\n`
break
}
case "table": {
const ths = t.header.map((h, i) => {
const a = t.aligns[i]
return a ? `<th align="${a}">${renderInline(h)}</th>` : `<th>${renderInline(h)}</th>`
}).join("\n")
let body = ''
for (const row of t.rows){
const tds = row.map((cell, i) => {
const a = t.aligns[i]
return a ? `<td align="${a}">${renderInline(cell)}</td>` : `<td>${renderInline(cell)}</td>`
}).join("\n")
body += `<tr>\n${tds}\n</tr>\n`
}
out += `<table>\n<thead>\n<tr>\n${ths}\n</tr>\n</thead>\n` + (body ? `<tbody>\n${body}</tbody>\n` : '') + `</table>\n`
break
}
case "hr":
out += "<hr>\n"
break
case "html":
out += t.text + "\n"
break
}
}
return out.trim()
}object
%shorthands
/phlo/resources/DOM/shorthands.phlo
view
script
line 10
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }object
%store
/phlo/resources/DOM/store.phlo
view
script
line 10
phlo.store = {
signals: {},
listeners: {},
calcs: {},
calcDeps: {},
calcVals: {},
calcTick: false,
split: (path) => path.replace(/\]/g, '').split(/\.|\[/),
get(path){
if (!path) return undefined
let ctx = phlo.store.signals
const keys = phlo.store.split(path)
for (let i = 0; i < keys.length; i++){
if (ctx == null) return undefined
ctx = ctx[keys[i]]
}
return ctx
},
setPath(path, value){
let keys = phlo.store.split(path)
let ctx = phlo.store.signals
while (keys.length > 1){
const k = keys.shift()
ctx[k] ??= isNaN(keys[0]) ? {} : []
ctx = ctx[k]
}
const k = keys[0]
const old = ctx[k]
if (old === value) return false
ctx[k] = value
return true
},
set(path, value){
if (!phlo.store.setPath(path, value)) return
phlo.store.notify(path, phlo.store.get(path))
phlo.store.recalc(path)
phlo.store.schedule()
},
on(path, cb){ (phlo.store.listeners[path] ??= new Set).add(cb) },
off(path, cb){ phlo.store.listeners[path] && phlo.store.listeners[path].delete(cb) },
reset(){
phlo.store.signals = {}
phlo.store.listeners = {}
phlo.store.calcs = {}
phlo.store.calcDeps = {}
phlo.store.calcVals = {}
phlo.store.calcTick = false
},
signal(path, initial){
if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
return { subscribe: (cb) => phlo.store.on(path, cb), unsubscribe: (cb) => phlo.store.off(path, cb) }
},
notify(path, val){
const set = phlo.store.listeners[path]
if (set) set.forEach(cb => cb(val))
},
match(dep, changed){
if (!dep) return false
if (dep === changed) return true
return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
},
depsReady(list){
const arr = Array.isArray(list) ? list : (list ? [list] : [])
return arr.every(d => phlo.store.get(d) !== undefined)
},
evalCalc(name){
const fn = phlo.store.calcs[name]
if (!fn) return
let deps = []
let val
try {
const out = fn()
if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
else val = out
}
catch(e){
deps = []
val = undefined
}
const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
phlo.store.calcDeps[name] = list
if (!phlo.store.depsReady(list)) return
const old = phlo.store.calcVals[name]
if (old !== val){
phlo.store.calcVals[name] = val
const p = `calc.${name}`
phlo.store.setPath(p, val)
phlo.store.notify(p, val)
}
},
recalc(changed){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++){
const name = names[i]
const deps = phlo.store.calcDeps[name] || []
for (let j = 0; j < deps.length; j++){
if (phlo.store.match(deps[j], changed)){
phlo.store.evalCalc(name)
break
}
}
}
},
recalcAll(){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
},
schedule(){
if (phlo.store.calcTick) return
phlo.store.calcTick = true
setTimeout(() => {
phlo.store.calcTick = false
phlo.store.recalcAll()
})
},
proxy(base){
return new Proxy({}, {
get(t, k){
if (typeof k === 'symbol') return undefined
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
const path = base + seg
const v = phlo.store.get(path)
if (v !== undefined && (typeof v !== 'object' || v === null)) return v
return phlo.store.proxy(path)
},
set(t, k, v){
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
phlo.store.set(base + seg, v)
return true
},
has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
}
}
app.store = phlo.store.proxy('')
app.mod.store = (key, value) => {
const cur = phlo.store.get(key)
if (JSON.stringify(cur) === JSON.stringify(value)) return
const walk = (base, obj) => {
if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
}
walk(key, value)
}
phlo.calc = new Proxy({}, {
set(t, k, fn){
if (typeof fn !== 'function') return false
phlo.store.calcs[k] = fn
phlo.store.evalCalc(k)
setTimeout(() => phlo.store.evalCalc(k))
return true
},
get(t, k){ return phlo.store.calcs[k] },
has(t, k){ return k in phlo.store.calcs },
deleteProperty(t, k){
delete phlo.store.calcs[k]
delete phlo.store.calcDeps[k]
delete phlo.store.calcVals[k]
return true
}
})
app.calc = new Proxy({}, {
get(t, k){ return phlo.store.calcVals[k] },
has(t, k){ return k in phlo.store.calcVals },
ownKeys(){ return Object.keys(phlo.store.calcVals) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
onExist('[data-bind]', (el) => {
const key = el.dataset.bind
const isCalc = key.startsWith('calc.')
const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
const fromDom = isInput ? el.value : el.textContent
const fromStore = phlo.store.get(key)
const domNonEmpty = (fromDom ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && domNonEmpty && storeEmpty
const S = (v) => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = (v) => {
const s = S(v)
if (isInput) el.value = s
else el.textContent = s
}
phlo.store.on(key, apply)
if (domLeads) phlo.store.set(key, fromDom)
const initial = domLeads ? fromDom : fromStore
apply(initial)
if (!isCalc && isInput) el.oninput = (e) => phlo.store.set(key, e.target.value)
})
onExist('[data-bind-attr]', (el) => {
const spec = el.getAttribute('data-bind-attr')
if (!spec) return
const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
let meta = phlo.existing.get(el)
if (!meta || typeof meta !== 'object'){
meta = { exist: true }
phlo.existing.set(el, meta)
}
meta.attr || (meta.attr = {})
meta.attr.cls || (meta.attr.cls = [])
spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
if (!m) return
const name = m[1]
const path = m[2]
const isCalc = path.startsWith('calc.')
const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
const domVal =
name === 'text' ? el.textContent :
name === 'html' ? el.innerHTML :
name === 'value' ? el.value :
(name === 'class' ? null : el.getAttribute(name))
const fromStore = phlo.store.get(path)
const domNonEmpty = (domVal ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
const S = (v) => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = (v) => {
if (name === 'text') el.textContent = S(v)
else if (name === 'html') app.mod.inner(el, S(v))
else if (name === 'value') app.mod.value(el, S(v))
else if (name === 'class'){
const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
const uniq = [...new Set(next.filter(Boolean))]
const prev = meta.attr.cls
for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
meta.attr.cls = uniq
}
else if (BOOL.has(name)){
const on = !!v
app.mod.attr(el, { [name]: on ? '' : null })
if (name in el) el[name] = on
}
else app.mod.attr(el, { [name]: S(v) })
}
phlo.store.on(path, apply)
if (domLeads) phlo.store.set(path, domVal)
const initial = domLeads ? domVal : fromStore
apply(initial)
if (!isCalc && isInputVal) el.oninput = (e) => phlo.store.set(path, e.target.value)
})
})object
%template
/phlo/resources/DOM/template.phlo
view
script
line 10
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}object
%timestamps
/phlo/resources/DOM/timestamps.phlo
view
script
line 10
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
const future = age < 0
if (future) age = -age
for (const [range, multiplier] of Object.entries(ranges)){
if (text) continue
if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
age /= multiplier
}
text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
text = `${future ? '-' : ''}${text}`
el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)object
%toasts
/phlo/resources/DOM/toasts.phlo
view
script
line 10
app.mod.toast = msg => {
obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
const toast = document.createElement('div')
toast.innerHTML = msg
toast.onclick = () => toast.remove()
obj('#toasts').insertAdjacentElement('beforeend', toast)
setTimeout(() => toast.remove(), 4000)
}view
style
line 21
#toasts {
position: fixed
right: 10px
top: 5px
z-index: 1001
> * {
background-color: #000A
border-radius: 10px
clear: both
color: white
cursor: zoom-out
float: right
margin-top: 5px
padding: 3px 6px
}
}object
%visible
/phlo/resources/DOM/visible.phlo
view
script
line 10
phlo.observe = []
phlo.observing = new WeakMap
const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})
app.updates.push(() => {
const observers = []
phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})object
%websocket
/phlo/resources/DOM/websocket.phlo
view
script
line 10
app.websocket = {
get open(){
app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
phlo.wss?.close()
phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
phlo.wss.onopen = e => [(cb = app.websocket.connect) && cb(e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]
phlo.wss.onerror = e => [(cb = app.websocket.error) && cb(e), phlo.log('🖧 Websocket error', e)]
phlo.wss.onclose = e => [(cb = app.websocket.close) && cb(e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
})
},
path: 'websocket',
send: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),
retry: 333,
}
app.websocket.openfields
object
%field
/phlo/resources/fields/field.phlo
function
function field ($type, ...$args)
line 10
phlo("field_$type", ...$args, type: $type)static
field :: __handle
line 12
nullprop
%field -> title
line 14
ucfirst($this->name)method
%field -> input ($record)
line 16
input(type: $this->type, name: $this->name, value: $record->{$this->name} ?? $this->default, maxlength: $this->length, placeholder: $this->placeholder, class: 'field')method
%field -> label ($record)
line 17
$record->{$this->name};prop
%field -> objColumns
line 19
[]method
%field -> objValidate ($value)
line 21
if (($value === null || $value === void) && $this->required) return $this->title.' is required'
if ($value === null || $value === void) return null
if ($this->length && is_string($value) && mb_strlen($value) > $this->length) return $this->title.' is too long (max '.$this->length.')'
if ($this->pattern && is_string($value) && !preg_match('/'.str_replace('/', '\\/', $this->pattern).'/', $value)) return $this->title.' has invalid format'
if ($this->enum && !in_array($value, (array)$this->enum, true)) return $this->title.' must be one of: '.implode(', ', (array)$this->enum)
return nullobject
%field_bool
/phlo/resources/fields/bool.phlo
prop
%field_bool -> true
line 11
'✅'prop
%field_bool -> false
line 12
'❌'method
%field_bool -> label ($record)
line 14
$record->{$this->name} ? $this->true : $this->falsemethod
%field_bool -> input ($record)
line 15
tag('label', inner: input(type: 'checkbox', name: $this->name, value: 1, checked: $record->{$this->name} ? false : null).tag('span', class: 'slider', inner: void))method
%field_bool -> parse ($record)
line 16
$record->{$this->name} = (bool)%payload->{$this->name};method
%field_bool -> nullable
line 17
falseprop
%field_bool -> objColumns
line 19
[$this->name]object
%field_child
/phlo/resources/fields/child.phlo
prop
%field_child -> list
line 11
'count'prop
%field_child -> change
line 12
falseprop
%field_child -> create
line 13
falseprop
%field_child -> record
line 14
'list'method
%field_child -> count ($record)
line 16
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id.slash.$this->name, class: 'async', inner: $record->getCount($this->name).space.$this->title)method
%field_child -> last ($record)
line 17
$record->getLast($this->name)method
%field_child -> label ($record)
line 18
implode(loop($record->{$this->name}, fn($child) => $this->link($child))) ?: dashmethod
%field_child -> input ($record)
line 19
$this->label($record)method
%field_child -> link ($record)
line 20
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id, class: 'async', inner: $record)object
%field_date
/phlo/resources/fields/date.phlo
prop
%field_date -> handle
line 11
trueprop
%field_date -> objColumns
line 13
[$this->name]object
%field_datetime
/phlo/resources/fields/datetime.phlo
prop
%field_datetime -> handle
line 11
trueprop
%field_datetime -> change
line 12
!in_array($this->name, ['created', 'changed'])prop
%field_datetime -> create
line 13
!in_array($this->name, ['created', 'changed'])method
%field_datetime -> label ($record)
line 15
($value = $record->{$this->name}) ? tag('i', class: 'icon clock-'.$this->labelIconClass(time() - $value), inner: void).tag('span', data_ts: $record->{$this->name}, inner: time_human($record->{$this->name})) : dashmethod
%field_datetime -> labelIconClass ($value)
line 16
$value > 86400 ? 'red' : ($value > 3600 ? 'yellow' : 'blue')method
%field_datetime -> input ($record)
line 17
input(type: 'datetime-local', name: $this->name, value: date('Y-m-d\TH:i', $record->{$this->name}), class: 'field')method
%field_datetime -> parse ($record)
line 18
if ($this->name === 'created') $record->created ??= time()
elseif ($this->name === 'changed') $record->{$this->name} = time()
elseif ($payload = %payload->{$this->name}) $record->{$this->name} = strtotime($payload)prop
%field_datetime -> objColumns
line 24
[$this->name]object
%field_email
/phlo/resources/fields/email.phlo
view
%field_email -> label ($record)
line 12
<a href="mailto:{{ $record->{$this->name} }}">{{ $record->{$this->name} }}</a>object
%field_file
/phlo/resources/fields/file.phlo
prop
%field_file -> canDelete
line 11
trueprop
%field_file -> delete
line 12
'Delete file?'prop
%field_file -> path
line 13
filesprop
%field_file -> uri
line 14
'/files/'prop
%field_file -> length
line 15
100method
%field_file -> label ($record)
line 17
($filename = $record->{$this->name}) ? tag('a', href: $this->uri.$record->{$this->name.'_token'}.slash.rawurlencode($filename), target: 'file', inner: tag('i', class: 'icon '.pathinfo($filename, PATHINFO_EXTENSION), inner: void).$filename) : dashmethod
%field_file -> input ($record)
line 18
$input = void
$file = ($filename = $record->{$this->name}) ? $this->read($record) : null
$input .= lf.tab.tag('div', class: 'file', inner: $file ? tag('i', class: "icon $file->ext", inner: void).$filename : '-select-')
$input .= lf.tab.input(type: 'file', class: 'file-input', name: $this->name, accept: '.pdf')
$file && $this->canDelete && $input .= lf.tab.tag('div', inner: tag('label', inner: input(type: 'checkbox', name: $this->name.'Delete')." $this->delete"))
return $input.lfmethod
%field_file -> parse ($record)
line 27
$file = %payload->{$this->name};
if ($delete = %payload->{$this->name.'Delete'}) unset(%payload->{$this->name.'Delete'})
if ($record->{$this->name} && $delete && $this->canDelete) $record->{$this->name} = $record->{$this->name.'_token'} = null
if (is_a($file, 'file')){
$token = $file->token
if (!$this->write($file)) return
$record->{$this->name} = $file->shortenTo($this->length)
$record->{$this->name.'_token'} = $token
}method
%field_file -> read ($record, $path = null)
line 39
$filename = $record->{$this->name};
$token = $record->{$this->name.'_token'};
return %file(($path ?? $this->path).($info = substr($token, 0, 2).slash.substr($token, 2).dot.pathinfo($filename, PATHINFO_EXTENSION)), name: $filename, info: $info)method
%field_file -> write ($file)
line 45
file_exists($dest = $this->writePath($file)) || $file->move($dest) || error("Couldn't write: $dest")method
%field_file -> writePath ($file, $path = null)
line 46
$token = $file->token
$path = ($path ?? $this->path).substr($token, 0, 2)
is_dir($path) || mkdir($path) || error("Couldn't create: $path")
return $path.slash.substr($token, 2).dot.$file->extprop
%field_file -> objColumns
line 53
[$this->name, $this->name.'_token']view
script
line 55
on('click', '.input.file .file', (file) => file.nextElementSibling.click())
on('change', '.input.file .file-input', (input) => input.previousElementSibling.innerText = input.files[0].name)object
%field_image
/phlo/resources/fields/image.phlo
prop
%field_image -> delete
line 11
'Delete image?'prop
%field_image -> uri
line 12
'/images/'prop
%field_image -> path
line 13
imagesprop
%field_image -> thumbPath
line 14
thumbsprop
%field_image -> thumbSize
line 15
64prop
%field_image -> thumbUri
line 16
'/thumbs/'method
%field_image -> label ($record)
line 18
($filename = $record->{$this->name}) ? tag('a', href: $this->uri.($url = $record->{$this->name.'_token'}.slash.rawurlencode($filename)), target: 'image', inner: tag('img', src: $this->thumbUri.$url)) : dashmethod
%field_image -> input ($record)
line 19
$input = void
$file = $record->{$this->name} ? $this->read($record, $this->thumbPath) : null
$input .= lf.tab.tag('img', src: $file ? $this->thumbUri.$record->{$this->name.'_token'}.slash.rawurlencode($record->{$this->name}) : '/image.select.jpg', class: 'image')
$input .= lf.tab.input(type: 'file', class: 'image-input', name: $this->name, accept: 'image/*', data_size: $this->thumbSize)
$file && $this->canDelete && $input .= lf.tab.tag('div', inner: tag('label', inner: input(type: 'checkbox', name: $this->name.'Delete')." $this->delete"))
return $input.lfmethod
%field_image -> write ($file)
line 28
%img($file->file)->scale($this->thumbSize, $this->thumbSize)->save($this->writePath($file, $this->thumbPath)) && $file->move($this->writePath($file))prop
%field_image -> objColumns
line 30
[$this->name, $this->name.'_token']view
script
line 32
on('click', '.input.image .image', (img) => img.nextElementSibling.click())
on('change', '.input.image .image-input', (input) => imageResizer(input.files[0], input.dataset.size, input.dataset.size, (image) => input.previousElementSibling.src = image))object
%field_many
/phlo/resources/fields/many.phlo
prop
%field_many -> list
line 11
'label'prop
%field_many -> record
line 12
'label'prop
%field_many -> create
line 13
falseprop
%field_many -> change
line 14
falsemethod
%field_many -> count ($record)
line 16
$record->getCount($this->name)method
%field_many -> label ($record)
line 17
loop($record->{$this->name}, fn($relation) => $this->link($relation), lf) ?: dashmethod
%field_many -> link ($record)
line 18
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id, class: 'async', inner: $record)method
%field_many -> input ($record)
line 19
$this->label($record)prop
%field_many -> objColumns
line 21
[]object
%field_multiselect
/phlo/resources/fields/multiselect.phlo
method
%field_multiselect -> label ($record)
line 11
($v = (string)($record->{$this->name} ?? '')) !== '' ? $v : dashmethod
%field_multiselect -> input ($record)
line 13
$current = array_filter(array_map('trim', explode(',', (string)($record->{$this->name} ?? ''))))
$js = "var f=this.closest('.field');f.querySelector('input.msv').value=[...f.querySelectorAll('input.msc:checked')].map(c=>c.value).join(',')"
$boxes = loop((array)$this->options, fn($o) => tag('label', tag('input', type: 'checkbox', value: (string)$o, class: 'msc', checked: in_array((string)$o, $current) ? true : void, onchange: $js).esc((string)$o), class: 'ms-opt'), void)
return tag('input', type: 'hidden', name: $this->name, value: implode(',', $current), class: 'msv').tag('div', $boxes ?: tag('span', 'geen opties', class: 'muted'), class: 'ms-grid')prop
%field_multiselect -> objColumns
line 20
[$this->name]object
%field_number
/phlo/resources/fields/number.phlo
prop
%field_number -> decimals
line 11
prop
%field_number -> length
line 12
5prop
%field_number -> min
line 13
method
%field_number -> label ($record)
line 15
number_format($record->{$this->name}, $this->decimals, comma, dot)method
%field_number -> input ($record)
line 16
input(type: 'number', name: $this->name, value: $record->{$this->name} ?? $this->default, step: $this->decimals ? dot.str_repeat('0', $this->decimals - 1).'1' : null, min: $this->min, class: 'field')prop
%field_number -> objColumns
line 18
[$this->name]object
%field_parent
/phlo/resources/fields/parent.phlo
method
%field_parent -> label ($record)
line 11
is_a($obj = $record->{$this->name}, 'model') ? $this->link($obj) : dashmethod
%field_parent -> input ($record)
line 12
select(name: $this->name, inner: loop($this->options, fn($parent) => '<option'.($parent->id === $record->{$this->name}?->id ? ' selected' : void).' value="'.$parent->id.'">'.$parent, void))method
%field_parent -> link ($record, $content = null)
line 13
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id, class: 'async', inner: $content ?? $record)prop
%field_parent -> options
line 14
$this->obj::records()prop
%field_parent -> objColumns
line 16
[$this->name]object
%field_password
/phlo/resources/fields/password.phlo
prop
%field_password -> list
line 11
falseprop
%field_password -> required
line 12
trueprop
%field_password -> minlength
line 13
8prop
%field_password -> placeholder
line 14
'New password'method
%field_password -> input ($record)
line 16
input(type: $this->type, name: $this->name, placeholder: $this->placeholder, class: 'field')method
%field_password -> label ($record)
line 17
'••••••••'method
%field_password -> parse ($record)
line 18
($password = trim(%payload->{$this->name})) && $record->{$this->name} = password_hash($password, PASSWORD_BCRYPT)prop
%field_password -> objColumns
line 20
[$this->name]object
%field_price
/phlo/resources/fields/price.phlo
prop
%field_price -> decimals
line 11
2object
%field_select
/phlo/resources/fields/select.phlo
method
%field_select -> input ($record)
line 11
select(name: $this->name, inner: loop($this->options, fn($option) => "<option".($record->{$this->name} === $option ? ' selected' : void).">$option", void))prop
%field_select -> objColumns
line 13
[$this->name]object
%field_text
/phlo/resources/fields/text.phlo
prop
%field_text -> length
line 11
100prop
%field_text -> multiline
line 12
$this->length > 250method
%field_text -> label ($record)
line 14
($value = $record->{$this->name}) === null ? dash : strtr(strlen($value) > 100 ? substr($value, 0, 80).'...' : $value, [lf => br])method
%field_text -> input ($record)
line 15
$this->multiline ? $this->inputMulti($record) : $this->inputField($record)method
%field_text -> inputField ($record)
line 16
input(type: $this->type, name: $this->name, value: ($value = $record->{$this->name}) ? esc($value) : $this->default, maxlength: $this->length, placeholder: $this->placeholder, class: 'field')method
%field_text -> inputMulti ($record)
line 17
textarea(name: $this->name, inner: ($value = $record->{$this->name}) ? esc($value) : $this->default ?? void, placeholder: $this->placeholder, class: 'field')prop
%field_text -> objColumns
line 19
[$this->name]object
%field_token
/phlo/resources/fields/token.phlo
prop
%field_token -> length
line 11
8prop
%field_token -> default
line 12
token($this->length)prop
%field_token -> create
line 13
falseprop
%field_token -> change
line 14
falseprop
%field_token -> search
line 15
truemethod
%field_token -> label ($record)
line 17
tag('div', inner: esc($record->{$this->name}))method
%field_token -> parse ($record)
line 18
$record->{$this->name} ??= $this->defaultprop
%field_token -> objColumns
line 20
[$this->name]object
%field_virtual
/phlo/resources/fields/virtual.phlo
prop
%field_virtual -> create
line 11
falseprop
%field_virtual -> change
line 12
falseprop
%field_virtual -> objColumns
line 14
[]object
%field_wysiwyg
/phlo/resources/fields/wysiwyg.phlo
method
%field_wysiwyg -> input ($record)
line 11
$value = $record->{$this->name} ?? $this->default ?? void
$toolbar = tag('div', class: 'toolbar', inner: tag('button', type: 'button', data_command: 'bold', inner: '<b>B</b>').tag('button', type: 'button', data_command: 'italic', inner: '<i>I</i>').tag('button', type: 'button', data_command: 'underline', inner: '<u>U</u>').tag('button', type: 'button', data_command: 'insertUnorderedList', inner: '•').tag('button', type: 'button', data_command: 'insertOrderedList', inner: '1.'))
$editor = tag('div', class: 'editor', contenteditable: 'true', inner: $value)
$hiddenInput = textarea(name: $this->name, class: 'hidden-value', inner: $value)
return tag('div', class: 'wysiwyg-container', inner: "$toolbar$editor$hiddenInput")prop
%field_wysiwyg -> objColumns
line 19
[$this->name]view
script
line 21
on('click', '.wysiwyg-container .toolbar button', (button, e) => {
e.preventDefault()
const command = button.dataset.command
document.execCommand(command, false, null)
})
on('input', '.wysiwyg-container .editor', (editor) => {
const container = editor.closest('.wysiwyg-container')
const hiddenInput = container.querySelector('textarea.hidden-value')
hiddenInput.value = editor.innerHTML
})
on('click', '.wysiwyg-container .editor a', (a) => window.open(a.href))Functions
function
field($type, ...$args)
/phlo/resources/fields/field.phlo line 10
phlo("field_$type", ...$args, type: $type)files
object
%CSV
/phlo/resources/files/CSV.phlo
static
CSV :: __handle
line 9
"CSV/$path$filename"method
%CSV -> __construct (string $filename, ?string $path = null)
line 10
$path ??= data
$this->objFile = $path.strtr($filename, [slash => dot]).'.csv'
if (is_readable($this->objFile)) $this->objRead()readonly
%CSV -> objFile:string
line 16
method
%CSV -> objRead
line 18
$fp = fopen($this->objFile, 'r+')
$headers = str_replace([dq, cr, lf], void, fgets($fp))
$delimiter = substr_count($headers, comma) > substr_count($headers, semi) ? comma : semi
$headers = explode($delimiter, $headers)
while ($row = fgetcsv($fp, null, $delimiter)) $this->objData[] = array_combine($headers, $row)
fclose($fp)object
%DOCX
/phlo/resources/files/DOCX.phlo
method
%DOCX -> __construct (string $file)
line 10
$zip = new ZipArchive()
if ($zip->open($file) !== true) dx('error opening docx', $file)
$xml = $zip->getFromName('word/document.xml')
$zip->close()
if (!$xml) dx('error reading document.xml')
$text = preg_replace('/<\/w:p>/', "\n", $xml)
$text = strip_tags($text)
$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8')
$this->text = trim(preg_replace('/[ \t]+/', ' ', $text))
$this->paragraphs = array_values(array_filter(explode("\n", $this->text), fn($p) => trim($p) !== void))static
DOCX :: toText (string $file):string
line 23
(new static($file))->textobject
%file
/phlo/resources/files/file.phlo
static
file :: __handle
line 9
"file/$file".($name ? "/$name" : void)method
%file -> __construct (public string $file, ?string $name = null, $contents = null, ...$args)
line 10
$name && $this->name = $name
is_string($contents) && $this->write($contents)
$args && $this->objImport(...$args)method
%file -> append (string $data)
line 16
file_put_contents($this->file, $data, FILE_APPEND | LOCK_EX)prop
%file -> basename
line 17
pathinfo($this->file, PATHINFO_BASENAME)method
%file -> base64
line 18
base64_encode($this->contents)method
%file -> contents
line 19
file_get_contents($this->file)method
%file -> contentsINI (bool $parse = true)
line 20
parse_ini_string($this->contents, true, $parse ? INI_SCANNER_TYPED : INI_SCANNER_RAW)method
%file -> contentsJSON ($assoc = null)
line 21
json_decode($this->contents, $assoc)method
%file -> copy ($to)
line 22
copy($this->file, $to)method
%file -> created
line 23
filectime($this->file)method
%file -> createdAge
line 24
age($this->created)method
%file -> createdHuman
line 25
time_human($this->created)method
%file -> curl ($type = null, $filename = null)
line 26
new CURLFile($this->file, $type, $filename)method
%file -> delete
line 27
first($deleted = $this->exists && unlink($this->file), debug($deleted ? "Deleted $this->basename" : "Could not delete $this->basename"))method
%file -> exists
line 28
file_exists($this->file)prop
%file -> ext
line 29
pathinfo($this->name, PATHINFO_EXTENSION)prop
%file -> filename
line 30
pathinfo($this->file, PATHINFO_FILENAME)method
%file -> getLine
line 31
($line = fgets($this->pointer)) === false ? false : rtrim($line)method
%file -> getLength (int $length)
line 32
fread($this->pointer, $length)method
%file -> is (string $file)
line 33
$file === $this->filemethod
%file -> md5
line 34
md5_file($this->file)prop
%file -> mime
line 35
mime($this->name)method
%file -> modified
line 36
filemtime($this->file)method
%file -> modifiedAge
line 37
age($this->modified)method
%file -> modifiedHuman
line 38
time_human($this->modified)method
%file -> move ($to)
line 39
rename($this->file, $to) && $this->file = $toprop
%file -> name
line 40
$this->basenamemethod
%file -> output ($download = false)
line 41
output($this->contents, $this->name, $download)prop
%file -> path
line 42
realpath(pathinfo($this->file, PATHINFO_DIRNAME)).slashprop
%file -> pathRel
line 43
str_starts_with($this->file, app) ? substr($this->file, strlen(app)) : $this->fileprop
%file -> pointer
line 44
fopen($this->file, 'r+')method
%file -> readable
line 45
is_readable($this->file)method
%file -> src
line 46
"data:$this->mime;base64,$this->base64"method
%file -> size
line 47
filesize($this->file)method
%file -> sizeHuman (int $precision = 0)
line 48
size_human($this->size, $precision)method
%file -> sha1
line 49
sha1_file($this->file)method
%file -> shortenTo (int $length)
line 50
strlen($this->name) <= $length ? $this->name : substr($this->name, 0, $length - strlen($this->ext) - 3).dot.dot.dot.$this->extmethod
%file -> title
line 51
ucfirst(strtr(pathinfo($this->name, PATHINFO_FILENAME), [us => space]))method
%file -> token ($length = 20)
line 52
token($length, $this->sha1)method
%file -> type
line 53
substr($this->mime, 0, strpos($this->mime, slash))method
%file -> touch
line 54
touch($this->file)method
%file -> writable
line 55
is_writable($this->file)method
%file -> writeINI ($data, bool $deleteEmpty = false)
line 56
$this->write(!$deleteEmpty || $data ? loop($data, fn($value, $key) => $key.' = '.dq.strtr($value, [dq => bs.dq, lf => '\n']).dq, lf).lf : void, $deleteEmpty)method
%file -> writeJSON ($data, bool $deleteEmpty = false)
line 57
$this->write(!$deleteEmpty || $data ? json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : void, $deleteEmpty)method
%file -> writeJSONplain ($data, bool $deleteEmpty = false)
line 58
$this->write(!$deleteEmpty || $data ? json_encode($data) : void, $deleteEmpty)method
%file -> write (string $data, bool $deleteEmpty = false)
line 59
if (!$data && $deleteEmpty) return $this->delete
if ($written = file_put_contents($this->file, $data, LOCK_EX) !== false) debug('Written '.$this->basename.' ('.$this->sizeHuman.')')
else error('Could not write '.$this->file)
return $writtenmethod
%file -> objInfo
line 66
array_combine($keys = array_merge(['file', 'name', 'exists'], $this->exists ? ['sizeHuman', 'createdHuman', 'modifiedHuman', 'mime'] : []), loop($keys, fn($arg) => $this->$arg))object
%img
/phlo/resources/files/img.phlo
static
img :: detect ($data)
line 9
$header = substr($data, 0, 12)
if (substr($header, 0, 3) === "\xFF\xD8\xFF") return 'jpg'
if (substr($header, 0, 8) === "\x89PNG\x0D\x0A\x1A\x0A") return 'png'
if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') return 'gif'
if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') return 'webp'
if (substr($header, 0, 2) === "BM") return 'bmp'
if (substr($header, 0, 4) === "\x49\x49\x2A\x00" || substr($header, 0, 4) === "\x4D\x4D\x00\x2A") return 'tiff'static
img :: search ($search)
line 19
$q = strtr($search, [dash => '+'])
$DOM = HTTP("http://images.google.it/images?as_q=$q&hl=it&imgtbs=z&btnG=Cerca+con+Google&as_epq=&as_oq=&as_eq=&imgtype=&imgsz=m&imgw=&imgh=&imgar=&as_filetype=&imgc=&as_sitesearch=&as_rights=&safe=images&as_st=y", agent: true)
$sources = regex_all('/var s=\'data:image\/jpeg;base64,([^\']{1000,})/', $DOM)[1]
usort($sources, fn($a, $b) => strlen($b) <=> strlen($a))
$sources = array_slice($sources, 0, 5)
shuffle($sources)
$source = current($sources)
$source = base64_decode($source)
return $sourcestatic
img :: __handle
line 31
"img/$file"method
%img -> __construct (public string $file)
line 32
prop
%img -> src:GdImage
line 34
imagecreatefromstring(file_get_contents($this->file))prop
%img -> width
line 35
imagesx($this->src)prop
%img -> height
line 36
imagesy($this->src)method
%img -> scale ($width = null, $height = null, $crop = false)
line 38
if (!$width && !$height) return $this
$srcW = $this->width
$srcH = $this->height
$doCrop = ($crop && $width && $height)
if ($width && $height) $scale = $doCrop ? max($width / $srcW, $height / $srcH) : min($width / $srcW, $height / $srcH)
elseif ($width) $scale = $width / $srcW
else $scale = $height / $srcH
if ($scale >= 1) return $this
$scaledW = (int)round($srcW * $scale)
$scaledH = (int)round($srcH * $scale)
$destW = ($width && $height && $doCrop) ? (int)$width : $scaledW
$destH = ($width && $height && $doCrop) ? (int)$height : $scaledH
$offsetX = 0
$offsetY = 0
if ($width && $height && $doCrop){
$offsetX = (int)-round(($scaledW - $destW) / 2)
$offsetY = (int)-round(($scaledH - $destH) / 2)
if ($crop === 'top') $offsetY = 0
elseif ($crop === 'bottom') $offsetY = (int)-($scaledH - $destH)
}
$destImg = imagecreatetruecolor($destW, $destH)
imagealphablending($destImg, false)
imagesavealpha($destImg, true)
imagecopyresampled($destImg, $this->src, $offsetX, $offsetY, 0, 0, $scaledW, $scaledH, $srcW, $srcH)
$this->src = $destImg
$this->width = $destW
$this->height = $destH
return $thismethod
%img -> ext ($file = null)
line 69
strtolower(pathinfo($file ?? $this->file, PATHINFO_EXTENSION))method
%img -> source ($format = null)
line 71
ob_start()
$this->write($format)
return ob_get_clean()method
%img -> save ($file = null)
line 77
$file && $this->file = $file
return $this->write(null, $this->file)method
%img -> write ($format = null, $file = null)
line 82
$format ??= $this->ext()
if ($format === 'png') return imagepng($this->src, $file, 8)
if ($format === 'gif') return imagegif($this->src, $file)
if ($format === 'webp'){
imageistruecolor($this->src) || imagepalettetotruecolor($this->src)
return imagewebp($this->src, $file)
}
return imagejpeg($this->src, $file, 85)object
%INI
/phlo/resources/files/INI.phlo
prop
%INI -> objFile:string
line 9
static
INI :: __handle
line 11
"INI/$path$filename".(!$parse ? '/0' : void)method
%INI -> __construct (string $filename, ?string $path = null, bool $parse = true)
line 12
$path ??= data
$this->objFile = $path.strtr($filename, [slash => dot]).'.ini'
if (is_readable($this->objFile)) $this->objRead($parse)method
%INI -> objRead ($parse = true)
line 18
last($this->objData = parse_ini_file($this->objFile, true, $parse ? INI_SCANNER_TYPED : INI_SCANNER_RAW), $this->objChanged = false, $this)method
%INI -> objWrite
line 19
file_put_contents($this->objFile, loop($this->objData, fn($value, $key) => $key.' = '.dq.strtr($value, [dq => bs.dq, lf => '\n']).dq, lf).lf, LOCK_EX)method
%INI -> __destruct
line 21
$this->objChanged && $this->objWrite()object
%JSON
/phlo/resources/files/JSON.phlo
static
JSON :: __handle
line 10
"JSON/$path$filename".(is_bool($assoc) ? slash.(int)$assoc : void)method
%JSON -> __construct (string $filename, ?string $path = null, $assoc = null)
line 11
$path ??= data
$this->objFile = "$path$filename.json"
if (is_readable($this->objFile)) $this->objRead($assoc)readonly
%JSON -> objFile:string
line 17
method
%JSON -> objTouch
line 19
$this->objChanged = truemethod
%JSON -> objRead ($assoc = null)
line 20
last($data = json_read($this->objFile, $assoc), $this->objData = $assoc || is_array($data) ? $data : get_object_vars($data), $this->objChanged = false, $this)method
%JSON -> objWrite ($data, $flags = null)
line 21
first($written = json_write($this->objFile, $data, $flags), $written && $this->objChanged = false)method
%JSON -> __destruct
line 23
$this->objChanged && $this->objWrite($this->objData)object%PDF
/phlo/resources/files/PDF.phlo
static
PDF :: toText (string $file):string
line 9
$process = proc_open('pdftotext '.escapeshellarg($file).' -', [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes)
if (!is_resource($process)) return null
fclose($pipes[0])
$text = stream_get_contents($pipes[1])
fclose($pipes[1])
$error = stream_get_contents($pipes[2])
fclose($pipes[2])
($code = proc_close($process)) && error("PDFToText Error: pdftotext command failed with code $code. Error: $error")
return rtrim($text, "\f")prop
%PDF -> title
line 21
nullprop
%PDF -> author
line 22
nullprop
%PDF -> subject
line 23
nullprop
%PDF -> keywords
line 24
nullprop
%PDF -> creator
line 25
'Phlo '.phlo.' (https://phlo.tech/)'prop
%PDF -> filename
line 27
'Download.pdf'prop
%PDF -> mode
line 28
'D'method
%PDF -> fromHTML ($HTML)
line 30
$mpdf = new \Mpdf\Mpdf
$this->title && $mpdf->SetTitle($this->title)
$this->author && $mpdf->SetAuthor($this->author)
$this->subject && $mpdf->SetSubject($this->subject)
$this->keywords && $mpdf->SetKeywords($this->keywords)
$this->creator && $mpdf->SetCreator($this->creator)
$mpdf->WriteHTML($HTML)
return $mpdf->Output($this->filename, $this->mode)object
%XLSX
/phlo/resources/files/XLSX.phlo
method
%XLSX -> __construct (string $file)
line 10
$sheets = []
$shared = []
$sheetNames = []
$zip = new ZipArchive()
if ($zip->open($file) !== true) dx('error opening zip', $file)
for ($i = 0; $i < $zip->numFiles; $i++){
$name = $zip->getNameIndex($i)
if ($name === false) continue
if (dirname($name) === 'xl/worksheets') $sheets[filter_var($name, FILTER_SANITIZE_NUMBER_INT)] = $zip->getFromIndex($i)
elseif ($name === 'xl/sharedStrings.xml'){
$xml = $zip->getFromIndex($i)
if (!preg_match_all('/<t[^>]*>(.*?)<\/t>/s', $xml, $m)) dx('error reading shared lib')
$shared = array_map(fn($t) => html_entity_decode($t, ENT_QUOTES | ENT_XML1, 'UTF-8'), $m[1])
}
elseif ($name === 'xl/workbook.xml'){
$xml = $zip->getFromIndex($i)
if (!preg_match_all('/<sheet[^>]*name="([^"]+)"[^>]*sheetId="([0-9]+)"/', $xml, $m)) dx('error reading workbook')
$sheetNames = $m[1]
}
}
$zip->close()
$toIndex = fn($letters) => array_reduce(str_split(strtoupper($letters)), fn($n, $c) => $n * 26 + ord($c) - 64, 0) - 1
$isShared = fn($attrs) => preg_match('/\bt="s"\b/', $attrs) === 1
foreach ($sheets AS $sheetID => $sheet){
$name = $sheetNames[$sheetID - 1] ?? 'Sheet '.$sheetID
if (!preg_match('/<row[^>]*>(.+)<\/row>/s', $sheet, $m)) dx('error parsing sheet')
$rowsXml = preg_split('/<\/row><row[^>]*>/', $m[1]) ?: []
$headerMap = []
$isHeader = true
foreach ($rowsXml AS $rowXml){
$rowXml = preg_replace('/<c([^>]*)\/>/', '<c$1></c>', $rowXml)
if (!preg_match_all('/<c r="([A-Z]+)[0-9]+"([^>]*)>(?:<f\b[^>]*\/?>)?(?:(?:<v>([^<]*)<\/v>)|(?:<is>.*?<t[^>]*>(.*?)<\/t>.*?<\/is>))?<\/c>/s', $rowXml, $mm)) dx('error parsing row', $rowXml)
if ($isHeader){
foreach (array_keys($mm[0]) AS $i){
$col = $toIndex($mm[1][$i])
$attrs = $mm[2][$i]
$valV = $mm[3][$i] ?? null
$valIS = $mm[4][$i] ?? null
$val = $valV !== null && $valV !== void ? $valV : ($valIS !== null && $valIS !== void ? html_entity_decode($valIS, ENT_QUOTES | ENT_XML1, 'UTF-8') : null)
$txt = $isShared($attrs) ? ($shared[$val] ?? null) : $val
$headerMap[$col] = $txt !== null && $txt !== void ? $txt : 'col'.$col
}
$isHeader = false
}
else {
$rowArr = []
foreach (array_keys($mm[0]) AS $i){
$col = $toIndex($mm[1][$i])
$attrs = $mm[2][$i]
$valV = $mm[3][$i] ?? null
$valIS = $mm[4][$i] ?? null
$val = $valV !== null && $valV !== void ? $valV : ($valIS !== null && $valIS !== void ? html_entity_decode($valIS, ENT_QUOTES | ENT_XML1, 'UTF-8') : null)
$key = $headerMap[$col] ?? 'col'.$col
$rowArr[$key] = $isShared($attrs) ? ($shared[$val] ?? null) : $val
}
$this->objData[$name][] = $rowArr
}
}
}security
object
%audit
/phlo/resources/security/audit.phlo
method
%audit -> log ($model, $action, $before = [], $after = [], $exclude = [])
line 10
$class = is_object($model) ? get_class($model) : (string)$model
$pk = (is_string($class) && class_exists($class) && property_exists($class, 'idColumn')) ? $class::$idColumn : 'id'
$id = is_object($model) ? ($model->$pk ?? $model->id ?? null) : null
if ($id === null) return
$before = (array)$before
$after = (array)$after
foreach ($exclude AS $col) unset($before[$col], $after[$col])
$changes = $action === 'update' ? $this->diff($before, $after) : ($action === 'create' ? $after : $before)
%MySQL->query(
'INSERT INTO audit_log (ts, user, model, record_id, action, changes, ip) VALUES (?, ?, ?, ?, ?, ?, ?)',
time(),
isset(%session->user) ? (int)%session->user : null,
$class,
(string)$id,
$action,
json_encode($changes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
(string)($_SERVER['REMOTE_ADDR'] ?? null),
)method
%audit -> diff ($before, $after)
line 31
$changed = []
foreach ($after AS $col => $newVal){
$oldVal = $before[$col] ?? null
if ($oldVal !== $newVal) $changed[$col] = ['from' => $oldVal, 'to' => $newVal]
}
return $changedmethod
%audit -> history ($model, $recordId, $limit = 50)
line 40
$class = is_object($model) ? get_class($model) : (string)$model
return %MySQL->query(
'SELECT * FROM audit_log WHERE model=? AND record_id=? ORDER BY ts DESC LIMIT ?',
$class, (string)$recordId, (int)$limit,
)->fetchAll(\PDO::FETCH_OBJ)method
%audit -> byUser ($userId, $fromTs = 0, $limit = 100)
line 48
return %MySQL->query(
'SELECT * FROM audit_log WHERE user=? AND ts >= ? ORDER BY ts DESC LIMIT ?',
(int)$userId, (int)$fromTs, (int)$limit,
)->fetchAll(\PDO::FETCH_OBJ)method
%audit -> purge ($olderThanSeconds = 31536000)
line 55
%MySQL->query('DELETE FROM audit_log WHERE ts < ?', time() - $olderThanSeconds)object
%creds
/phlo/resources/security/creds.phlo
method
%creds -> __construct (?array $values = null)
line 9
$values ??= $this->resolve()
foreach ($values AS $key => $value){
$this->$key = is_array($value) ? new static($value) : new \SensitiveParameterValue((string)$value)
}method
%creds -> resolve
line 16
$data = []
$this->merge($data, $this->loadINI(data.'creds.ini'))
$this->merge($data, $this->envValues(false))
$this->merge($data, $this->envValues(true))
return $datamethod
%creds -> loadINI (string $file):array
line 24
if (!is_file($file)) return []
$ini = parse_ini_file($file, true, INI_SCANNER_RAW)
return is_array($ini) ? $ini : []method
%creds -> envValues (bool $hostScoped = false):array
line 30
$out = []
$prefix = $hostScoped ? ('PHLO_'.$this->hostKey().'__') : 'PHLO__'
$sources = []
is_array($_ENV ?? null) && $sources[] = $_ENV
is_array($_SERVER ?? null) && $sources[] = $_SERVER
is_array($env = getenv()) && $sources[] = $env
foreach ($sources AS $source){
foreach ($source AS $key => $value){
$key = (string)$key
if (!str_starts_with($key, $prefix)) continue
$path = substr($key, strlen($prefix))
if (!$path) continue
$this->envAssign($out, explode('__', $path), (string)$value)
}
}
return $outmethod
%creds -> hostKey
line 49
$host = strtoupper(%req->host)
$host = preg_replace('/[^A-Z0-9]+/', '_', $host)
return trim($host, '_')method
%creds -> envAssign (array &$target, array $parts, string $value):void
line 55
$parts = array_values(array_filter(loop($parts, fn($part) => trim($part)), 'strlen'))
if (!$parts) return
$node = &$target
$last = count($parts) - 1
foreach ($parts AS $i => $part){
if ($i === $last){
$node[$part] = $value
return
}
if (!isset($node[$part]) || !is_array($node[$part])) $node[$part] = []
$node = &$node[$part]
}method
%creds -> merge (array &$base, array $add):void
line 70
foreach ($add AS $key => $value){
if (isset($base[$key]) && is_array($base[$key]) && is_array($value)){
$this->merge($base[$key], $value)
continue
}
$base[$key] = $value
}method
%creds -> objGet ($key)
line 80
if ($key === 'toArray') return loop($this->objData, fn($value) => is_a($value, 'SensitiveParameterValue') ? $value->getValue() : $value)
if (isset($this->objData[$key]) && is_a($this->objData[$key], '\SensitiveParameterValue')) return $this->objData[$key]->getValue()method
%creds -> objInfo
line 85
loop($this->objData, fn($value) => is_a($value, '\SensitiveParameterValue') ? str_repeat('*', strlen($value->getValue())) : $value)object
%CSRF
/phlo/resources/security/CSRF.phlo
view
%CSRF -> view
line 11
<meta name=csrf content="$this->token">prop
%CSRF -> token
line 12
%session->csrf ??= token(32)method
%CSRF -> verify
line 13
hash_equals($this->token, (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? void))method
%CSRF -> update
line 14
arr(csrf: $this->token = %session->csrf = token(32))view
script
line 16
app.mod.csrf = value => obj('meta[name="csrf"]').content = valueobject
%JWT
/phlo/resources/security/JWT.phlo
method
%JWT -> __construct (public string $secret, public string $issuer = void, public int $leeway = 30)
line 10
strlen($this->secret) >= 32 || error('JWT secret must be at least 32 bytes', 500)method
%JWT -> sign (array $claims, int $ttl = 3600):string
line 14
$now = time()
$claims['iat'] = $now
$claims['exp'] = $now + $ttl
if ($this->issuer !== void) $claims['iss'] = $this->issuer
$body = $this->encode(['alg' => 'HS256', 'typ' => 'JWT']).dot.$this->encode($claims)
return $body.dot.$this->sig($body)method
%JWT -> verify (string $token):array
line 23
$token = preg_replace('/^Bearer\s+/i', void, trim($token))
$parts = explode(dot, $token)
count($parts) === 3 || error('JWT malformed', 401)
[$h, $p, $s] = $parts
$header = (array)json_decode((string)$this->decode($h), true)
($header['alg'] ?? void) === 'HS256' || error('JWT algorithm not allowed', 401)
hash_equals($this->sig($h.dot.$p), $s) || error('JWT signature invalid', 401)
$claims = (array)json_decode((string)$this->decode($p), true)
$now = time()
isset($claims['nbf']) && $now + $this->leeway < $claims['nbf'] && error('JWT not yet valid', 401)
isset($claims['exp']) && $now - $this->leeway >= $claims['exp'] && error('JWT expired', 401)
$this->issuer === void || ($claims['iss'] ?? void) === $this->issuer || error('JWT issuer mismatch', 401)
return $claimsmethod
%JWT -> sig (string $body):string
line 39
$this->encode(hash_hmac('sha256', $body, $this->secret, true))method
%JWT -> encode ($data):string
line 40
rtrim(strtr(base64_encode(is_string($data) ? $data : (string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)), '+/', '-_'), eq)method
%JWT -> decode (string $data):string
line 41
(string)base64_decode(strtr($data, '-_', '+/'))object
%rate
/phlo/resources/security/rate.phlo
method
%rate -> check ($key, $limit, $windowSeconds, $storage = 'db')
line 10
if ($storage === 'apcu') return $this->checkApcu($key, $limit, $windowSeconds)
$now = time()
$row = %MySQL->query('SELECT count, window_start FROM rate_limit WHERE rkey=?', $key)->fetchObject('obj') ?: null
if ($row && ($now - (int)$row->window_start) < $windowSeconds){
if ((int)$row->count >= $limit) return false
%MySQL->query('UPDATE rate_limit SET count=count+1 WHERE rkey=?', $key)
return true
}
%MySQL->query('INSERT INTO rate_limit (rkey, count, window_start) VALUES (?,1,?) ON DUPLICATE KEY UPDATE count=1, window_start=VALUES(window_start)', $key, $now)
return truemethod
%rate -> checkApcu ($key, $limit, $windowSeconds)
line 23
$window = (int)floor(time() / $windowSeconds) * $windowSeconds
$apcuKey = 'phlo.rate.'.$key.':'.$window
$count = apcu_inc($apcuKey, 1, $success, $windowSeconds)
if (!$success) apcu_store($apcuKey, 1, $windowSeconds)
return $count <= $limitmethod
%rate -> status ($key, $limit, $windowSeconds)
line 31
$now = time()
$row = %MySQL->query('SELECT count, window_start FROM rate_limit WHERE rkey=?', $key)->fetchObject('obj') ?: null
if (!$row || ($now - (int)$row->window_start) >= $windowSeconds) return obj(used: 0, limit: $limit, resetIn: 0)
return obj(used: (int)$row->count, limit: $limit, resetIn: max(0, ((int)$row->window_start + $windowSeconds) - $now))method
%rate -> reset ($key)
line 38
%MySQL->query('DELETE FROM rate_limit WHERE rkey=?', $key)method
%rate -> purge ($olderThanSeconds = 604800)
line 39
%MySQL->query('DELETE FROM rate_limit WHERE window_start < ?', time() - $olderThanSeconds)object
%security
/phlo/resources/security/security.phlo
prop
%security -> whitelist
line 10
[]method
%security -> setNonce
line 12
%app->nonce = token(8)method
%security -> frameProtect ($mode = 'DENY')
line 14
%res->header('X-Frame-Options', $mode)method
%security -> frameWhitelist
line 15
$this->whitelist ? ' '.implode(space, array_map(fn($d) => "https://*.$d", (array)$this->whitelist)) : voidmethod
%security -> strict
line 17
$this->base
if (%req->async) return
%res->header('Cache-Control', 'no-store')
$nonce = $this->setNonce
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'nonce-$nonce'; style-src 'self' 'nonce-$nonce'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")method
%security -> basic
line 24
$this->base
if (%req->async) return
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'self'".(debug ? " 'unsafe-inline'" : '')."; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")method
%security -> marketing
line 29
$this->base
if (%req->async) return
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'self'".(debug ? " 'unsafe-inline'" : '')."; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")method
%security -> api
line 34
%res->header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
%res->header('X-Content-Type-Options', 'nosniff')
%res->header('Referrer-Policy', 'no-referrer')method
%security -> base
line 40
%res->header('Referrer-Policy', 'strict-origin-when-cross-origin')
%res->header('X-Content-Type-Options', 'nosniff')
%req->async || %res->header('Cross-Origin-Opener-Policy', 'same-origin')
%req->async || %res->header('Cross-Origin-Resource-Policy', 'same-origin')
%req->async || %res->header('Access-Control-Allow-Origin', %req->base)
%req->async || %res->header('X-Frame-Options', 'DENY')Functions
function
decrypt($encrypted, $key):string|false
/phlo/resources/security/encryption.phlo line 10
($d = base64_decode($encrypted, true)) !== false && strlen($d) >= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ? sodium_crypto_secretbox_open(substr($d, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), substr($d, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), hash('sha256', $key, true)) : falsefunction
encrypt($data, $key):string
/phlo/resources/security/encryption.phlo line 8
base64_encode(($nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)).sodium_crypto_secretbox($data, $nonce, hash('sha256', $key, true)))function
token(int $length = 8, ?string $input = null)
/phlo/resources/security/token.phlo line 8
$length || error('Token must have a minimum length above 0', 500)
$alphabet = 'abcdefghijklmnopqrstuvwxyz'
$alphabetLength = strlen($alphabet)
$limit = intdiv(256, $alphabetLength) * $alphabetLength
$token = void
$buffer = void
$state = is_null($input) ? null : hash('sha256', (string)$input, true)
while (strlen($token) < $length){
if ($buffer === void){
if (is_null($state)) $buffer = random_bytes(32)
else {
$state = hash('sha256', $state, true)
$buffer = $state
}
}
$byte = ord($buffer[0])
$buffer = substr($buffer, 1)
if ($byte >= $limit) continue
$token .= $alphabet[$byte % $alphabetLength]
}
return $token