628 lines
16 KiB
PHP
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;
|
|
}
|
|
} |