clrghouz/app/Classes/BBS/Page.php
2024-05-28 12:44:55 +10:00

628 lines
16 KiB
PHP

<?php
namespace App\Classes\BBS;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use App\Classes\BBS\Exceptions\{NoRouteException,ParentNotFoundException};
use App\Classes\BBS\Frame\{Action,Field};
use App\Models\BBS\{Frame,Mode};
use App\Models\User;
/**
* The current page object
*
* @property page The full page number requested
*/
abstract class Page
{
/**
* Color attributes can fit in an int
* + Bit 0-2 off = Black foreground
* + Foreground colors bits (0-2)
* + High Intensity 1 bit (3)
* + Bit 4-6 off = Black background
* + Background colors bits (4-6)
* + Flash 1 bit (7)
*/
public const BLINK = 1<<7; /* blink bit */
public const HIGH = 1<<3; /* high intensity (bright) foreground bit */
/* foreground colors */
public const BLACK = 0; /* dark colors (HIGH bit unset) */
public const BLUE = 1;
public const GREEN = 2;
public const CYAN = 3;
public const RED = 4;
public const MAGENTA = 5;
public const BROWN = 6;
public const LIGHTGRAY = 7;
public const DARKGRAY = self::HIGH | self::BLACK; /* light colors (HIGH bit set) */
public const LIGHTBLUE = self::HIGH | self::BLUE;
public const LIGHTGREEN = self::HIGH | self::GREEN;
public const LIGHTCYAN = self::HIGH | self::CYAN;
public const LIGHTRED = self::HIGH | self::RED;
public const LIGHTMAGENTA = self::HIGH | self::MAGENTA;
public const YELLOW = self::HIGH | self::BROWN;
public const WHITE = self::HIGH | self::LIGHTGRAY;
public const BG_BLACK = 0x100; /* special value for ansi() */
public const BG_BLUE = (self::BLUE<<4);
public const BG_GREEN = (self::GREEN<<4);
public const BG_CYAN = (self::CYAN<<4);
public const BG_RED = (self::RED<<4);
public const BG_MAGENTA = (self::MAGENTA<<4);
public const BG_BROWN = (self::BROWN<<4);
public const BG_LIGHTGRAY = (self::LIGHTGRAY<<4);
public const FRAMETYPE_INFO = 'i';
public const FRAMETYPE_ACTION = 'a';
public const FRAMETYPE_RESPONSE = 'r';
public const FRAMETYPE_LOGIN = 'l';
public const FRAMETYPE_TERMINATE = 't';
public const FRAMETYPE_EXTERNAL = 'x';
private int $frame;
private string $index;
/** @var Mode Our BBS mode model object */
protected Mode $mo;
/** @var Frame|null Our frame model object */
private ?Frame $fo = NULL;
/** @var Collection Users page retrieval history */
private Collection $history;
/* Our window layout */
protected Window $layout;
private Window $content;
private Window $header;
private Window $provider;
private Window $pagenum;
private Window $unit;
private bool $showheader = FALSE;
/** @var array Our compiled page */
protected array $build;
/* Fields */
// Current field being edited
private ?int $field_active = NULL;
/** @var Collection Dynamic fields that are pre-populated with system data */
protected Collection $fields_dynamic;
/** @var Collection Input fields take input from the user */
protected Collection $fields_input;
protected bool $debug;
abstract public function attr(array $field): string;
abstract public function parse(string $contents,int $width,int $yoffset=0,int $xoffset=0,?int $debug=NULL): array;
abstract public static function strlenv($text): int;
public function __construct(int $frame,string $index='a',bool $debug=FALSE)
{
$this->debug = $debug;
$this->layout = new Window(1,1,static::FRAME_WIDTH,static::FRAME_HEIGHT+1,'LAYOUT',NULL,$debug);
$this->header = new Window(1,1,static::FRAME_WIDTH,1,'HEADER',$this->layout,$debug);
//dump(['this'=>get_class($this),'header_from'=>$this->header->x,'header_to'=>$this->header->bx,'width'=>$this->header->width]);
// Provider can use all its space
$this->provider = new Window(1,1,static::FRAME_PROVIDER_LENGTH,1,'PROVIDER',$this->header,$debug);
//dump(['this'=>get_class($this),'provider_from'=>$this->provider->x,'provider_to'=>$this->provider->bx,'width'=>$this->provider->width]);
// Page number is prefixed with a color change (if required, otherwise a space)
$this->pagenum = new Window($this->provider->bx+1,1,static::FRAME_PAGE_LENGTH,1,'#',$this->header,$debug);
//dump(['this'=>get_class($this),'pagenum_from'=>$this->pagenum->x,'pagenum_to'=>$this->pagenum->bx,'width'=>$this->pagenum->width]);
// Unit is prefixed with a color change (required, since a different color to page)
$this->unit = new Window($this->pagenum->bx+1,1,static::FRAME_COST_LENGTH,1,'$',$this->header,$debug);
//dump(['this'=>get_class($this),'unit_from'=>$this->unit->x,'unit_to'=>$this->unit->bx,'width'=>$this->unit->width]);
$this->content = new Window(1,2,static::FRAME_WIDTH,static::FRAME_HEIGHT,'CONTENT',$this->layout,$debug);
$this->resetHistory();
$this->clear();
$this->goto($frame,$index);
}
public function __get(string $key): mixed
{
switch ($key) {
case 'access' :
case 'id' :
case 'cls':
case 'cost':
case 'created_at':
case 'public' :
case 'type' :
return $this->fo?->{$key};
case 'cug': return $this->fo?->cug_id;
case 'frame':
case 'index':
return $this->{$key};
case 'next': return ($this->index < 'z') ? chr(ord($this->index)+1) : $this->index;
case 'prev': return ($this->index > 'a') ? chr(ord($this->index)-1) : $this->index;
case 'page': return sprintf('%d%s',$this->frame,$this->index);
case 'height': return $this->layout->height;
case 'width': return $this->layout->width;
case 'fields_input': return $this->fields_input;
case 'field_current': return (! is_null($this->field_active)) ? $this->fields_input->get($this->field_active): NULL;
default:
throw new \Exception('Unknown key: '.$key);
}
}
public function __set(string $key,mixed $value): void
{
switch ($key) {
case 'showheader':
$this->{$key} = $value;
break;
default:
throw new \Exception('Unknown key: '.$key);
}
}
public function __toString(): string
{
return $this->display()->join("");
}
/* METHODS */
/**
* Return a list of alternative versions of this frame.
*
* @todo: Need to adjust to not include access=0 frames unless owner
*/
public function alts(): Collection
{
return Frame::where('frame',$this->frame)
->where('index',$this->index)
->where('id','<>',$this->fo->id)
->where('mode_id',$this->id)
->where('access',1)
->limit(9)
->get();
}
private function atcode(string $name,int $length,mixed $pad=' '): string
{
switch ($name) {
case 'NODE':
$result = '00010001';
break;
case 'DATETIME':
$result = Carbon::now()->toRfc822String();
break;
case 'DATE':
$result = Carbon::now()->format('Y-m-d');
break;
case 'TIME':
$result = Carbon::now()->format('H:ia');
break;
default:
$result = $name;
}
if (strlen($result) < abs($length) && $pad)
$result = ($length < 0)
? Str::padLeft($result,abs($length),$pad)
: Str::padRight($result,abs($length),$pad);
return $result;
}
/**
* History go back to previous page
*
* @return bool
*/
public function back(): bool
{
if ($this->history->count() > 1) {
$this->history->pop();
$this->fo = $this->history->last();
return TRUE;
}
return FALSE;
}
/**
* Parse a page, extracting fields and formatting into our Window objects
*
* @param bool $force
* @return array
* @throws \Exception
*/
public function build(bool $force=FALSE): array
{
if ($this->build && ! $force)
throw new \Exception('Refusing to build without force.');
$this->load();
$test = FALSE;
$this->provider->content = $this->parse(($test ? chr(0x02).'T'.chr(0x03).'B'.chr(0x04) : 'TB').'A'.($test ? ' - 12345678901234567890123456789012345678901234567890123456' : ''),static::FRAME_PROVIDER_LENGTH,$this->provider->y,$this->provider->x);
$this->pagenum->content = $this->parse($this->color_page.($test ? '123456789012345a' : $this->page),static::FRAME_SPACE+static::FRAME_PAGE_LENGTH,$this->pagenum->y,$this->pagenum->x);
$this->unit->content = $this->parse($this->color_unit.Str::padLeft(($this->cost+($test ? 1234 : 0)).'c',static::FRAME_COST_LENGTH-1,' '),static::FRAME_SPACE+static::FRAME_COST_LENGTH,$this->unit->y,$this->unit->x);
$this->content->content = $this->parse($this->fo->content,static::FRAME_WIDTH,$this->content->y,$this->content->x);
$this->header->visible = ($this->showheader || $test);
$this->build_system_fields();
$this->build = $this->layout->build(1,1,$this->debug);
// Add our dynamic values
$fields = $this->fields_dynamic->filter(function($item) { return $item->value; });
Log::channel('bbs')->debug(sprintf('There are [%d] dynamic fields to populate',$fields->count()));
if ($fields->count())
$this->fields_insert($fields);
// Add our input fields
$fields = $this->fields_input->filter(function($item) { return is_null($item->value); });
Log::channel('bbs')->debug(sprintf('There are [%d] input fields to setup',$fields->count()));
if ($fields->count())
$this->fields_insert($fields);
return $this->build;
}
// @todo To complete - some of these came from SBBS and are not valid here
private function build_system_fields(): void
{
// Fields we can process automatically
$auto = ['NODE','DATETIME','DATE','TIME','REALNAME','BBS'];
$df = $this->fields_dynamic->filter(function($item) { return is_null($item->value); });
if (! $df->count())
return;
foreach ($df as $field) {
if (in_array($field->name,$auto))
$this->field_dynamic($field->name,$this->atcode($field->name,$field->size,$field->pad));
}
}
private function clear(): void
{
$this->build = [];
$this->fields_dynamic = collect();
$this->fields_input = collect();
$this->fieldReset();
}
// Insert our *_field data (if it is set)
public function display(): Collection
{
if (! $this->build)
throw new \Exception('Page not ready');
// build
$display = $this->build;
// populate dynamic fields - refresh dynamic fields if 09, otherwise show previous compiled with 00
// check if there are any dynamic fields with no values
switch ($this->mo->name) {
case 'ansi':
$new_line = NULL;
$shownullchars = TRUE;
break;
case 'viewdata':
$new_line = static::BG_BLACK|static::WHITE;
$shownullchars = FALSE;
break;
default:
throw new \Exception(sprintf('Dont know how to display a [%s] page',$this->mo->name));
}
$result = collect();
$last = $new_line;
if ($this->debug)
dump(['page-width'=>$this->width,'page-height'=>$this->height]);
// render
for ($y=1;$y<=$this->height;$y++) {
$line = '';
if ($new_line)
$last = $new_line;
if ($this->debug)
dump('============== ['.$y.'] ===============');
$x = 1;
while ($x <= $this->width) {
if ($this->debug)
dump('* CELL : y:'.$y.', x:'.$x);
// The current char value
$char = (isset($display[$y]) && isset($display[$y][$x])) ? $display[$y][$x] : NULL;
if ($this->debug)
dump(' - CHAR : '.(! is_null($char) ? $char->ch : 'undefined').', ATTR:'.(! is_null($char) ? $char->attr : 'undefined').', LAST:'.$last);
if ($this->debug) {
dump('-------- ['.$x.'] ------');
dump('y:'.$y.',x:'.$x.', attr:'.(! is_null($char) ? $char->attr : 'undefined'));
}
// Only write a new attribute if it has changed (and not Videotex)
if ($last !== $char->attr) {
// The current attribute for this character
$attr = is_null($char) ? NULL : $char->attr($this->mo,$last,$this->debug);
switch ($this->mo->name) {
case 'ansi':
// If the attribute is null, we'll write our default attribute
if (is_null($attr))
$line .= ''; #static::BG_BLACK|static::LIGHTGRAY;
else
$line .= (! is_null($attr)) ? $attr : '';
break;
case 'viewdata':
// If the attribute is null, we'll ignore it since we are drawing a character
if (! is_null($attr)) {
if ($this->debug)
dump(sprintf('= SEND attr:%02x, last: %02x [%s] (%s)',ord($attr),$last,$char->ch,serialize($attr)));
$line .= "\x1b".$attr;
//$x++;
}
break;
default:
throw new \Exception(sprintf('[%s] has not been implemented',$this->mo->name));
}
}
if (! is_null($char->ch)) {
if ($this->debug)
dump(' = SEND CHAR :'.$char->ch.', attr:'.$char->attr.', last:'.$last);
$line .= $char->ch;
} else if ($shownullchars || ((is_null($char->ch) && is_null($char->attr)))) {
if ($this->debug)
dump(' = CHAR UNDEFINED');
$line .= ' ';
}
$last = $char->attr;
$x++;
}
if ($this->debug)
dump(['line'=>$line]);
$result->push($line);
if ($this->debug && ($y > $this->debug))
exit(1);
}
return $result;
}
/**
* Update a dynamic field with a value
*
* @param $name
* @param $value
* @return void
* @throws \Exception
*/
private function field_dynamic($name,$value): void
{
if (($x=$this->fields_dynamic->search(function($item) use ($name) { return $item->name === $name; })) !== FALSE) {
$field = $this->fields_dynamic->get($x);
// Store our value
$field->value = $value;
} else {
throw new \Exception(sprintf('Dynamic field: [%s], doesnt exist?',$name));
}
}
private function fields_insert($fields) {
foreach ($fields as $field) {
if (is_null($field->value))
continue;
$content = str_split($field->value);
$y = $field->y;
$x = $field->x;
for ($x;$x < $field->x+abs($field->size);$x++) {
$index = $x-$field->x;
if (isset($content[$index]))
$this->build[$y][$x]->ch = ($field->type !== 'p') ? $content[$index] : '*';
else
$this->build[$y][$x]->ch = $field->pad;
}
}
}
public function fieldReset(): void
{
$this->field_active = NULL;
foreach ($this->fields_input as $field)
$field->value = NULL;
}
public function fieldNext(): Field|NULL
{
if ($this->fields_input->count()) {
if (is_null($this->field_active))
$this->field_active = 0;
else
$this->field_active++;
return $this->fields_input->get($this->field_active);
} else
return NULL;
}
/**
* Load a frame by it's ID.
*
* @param int $id
* @return void
*/
public function get(int $id): void
{
$this->po->findOrFail($id);
$this->frame = $this->po->frame;
$this->index = $this->po->index;
}
/**
* Go to a specific frame
*
* @param int $frame
* @param string $index
* @return void
* @throws \Exception
*/
public function goto(int $frame,string $index='a'): void
{
if (strlen($index) !== 1)
throw new \Exception('Invalid index:'.$index);
$this->frame = $frame;
$this->index = $index;
$this->fo = NULL;
}
public function haveNext(): bool
{
return $this->fo
? Frame::where('frame',$this->frame)
->where('index',$this->next)
->where('mode_id',$this->fo->mode_id)
->exists()
: FALSE;
}
public function isCug(int $cug): bool
{
return $this->cug === $cug;
}
// @todo To implement
public function isOwner(User $o): bool
{
return FALSE;
}
public function isRoute(int $route): bool
{
return is_numeric($this->fo->{sprintf('r%d',$route)});
}
/**
* Load a frame, throw a model not found exception if it doesnt exist
*
* @return void
*/
public function load(): void
{
$this->fo = Frame::where('mode_id',$this->mo->id)
->where('frame',$this->frame)
->where('index',$this->index)
->orderBy('created_at','DESC')
->firstOrFail();
$this->history->push($this->fo);
$this->clear();
}
public function method(int $route): ?Action
{
if (($x=($this->fo->{sprintf('r%d',$route)})) && (! $this->isRoute($route)))
return Action::factory($x);
return NULL;
}
public function new(int $frame,string $index='a'): void
{
$this->frame = $frame;
$this->index = $index;
$this->fo = new Frame;
// Make sure parent frame exists
if (($this->index !== 'a') && (! Frame::where('frame',$this->frame)->where('index',$this->prev)->where('mode',$this->mo->id)->exists()))
throw new ParentNotFoundException(sprintf('Parent %d%s doesnt exist',$frame,$index));
}
public function next(): void
{
$this->index = $this->next;
$this->fo = NULL;
}
/**
* Clear a user's history
*
* @return void
*/
public function resetHistory(): void
{
$this->history = collect();
}
public function route(int $route): void
{
if ($this->isRoute($route)) {
$this->frame = (int)$this->fo->{sprintf('r%d',$route)};
$this->index = 'a';
$this->fo = NULL;
} else {
throw new NoRouteException('Invalid route '.$route);
}
}
public function prev(): void
{
$this->index = $this->prev;
$this->fo = NULL;
}
}