yggverse
5 months ago
4 changed files with 0 additions and 1106 deletions
@ -1,277 +0,0 @@
@@ -1,277 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Yggverse\Gemini\Dokuwiki; |
||||
|
||||
class Filesystem |
||||
{ |
||||
private $_path; |
||||
private $_tree = []; |
||||
private $_list = []; |
||||
|
||||
public function __construct(string $path) |
||||
{ |
||||
$this->_path = rtrim( |
||||
$path, |
||||
'/' |
||||
); |
||||
|
||||
$this->_index( |
||||
$this->_path |
||||
); |
||||
} |
||||
|
||||
public function getTree(): array |
||||
{ |
||||
return $this->_tree; |
||||
} |
||||
|
||||
public function getList(): array |
||||
{ |
||||
return $this->_list; |
||||
} |
||||
|
||||
public function getPagePathsByPath(string $path): ?array |
||||
{ |
||||
if (isset($this->_tree[$path])) |
||||
{ |
||||
return $this->_tree[$path]; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
public function getPagePathByUri(string $uri): ?string |
||||
{ |
||||
$path = sprintf( |
||||
'%s/pages/%s.txt', |
||||
$this->_path, |
||||
str_replace( |
||||
':', |
||||
'/', |
||||
mb_strtolower( |
||||
urldecode( |
||||
$uri |
||||
) |
||||
) |
||||
) |
||||
); |
||||
|
||||
if (!$this->isPath($path)) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
return $path; |
||||
} |
||||
|
||||
public function getPageUriByPath(string $path): ?string |
||||
{ |
||||
if (!$this->isPath($path)) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
$path = str_replace( |
||||
sprintf( |
||||
'%s/pages/', |
||||
$this->_path |
||||
), |
||||
'', |
||||
$path |
||||
); |
||||
|
||||
$path = trim( |
||||
$path, |
||||
'/' |
||||
); |
||||
|
||||
$path = str_replace( |
||||
[ |
||||
'/', |
||||
'.txt' |
||||
], |
||||
[ |
||||
':', |
||||
null |
||||
], |
||||
$path |
||||
); |
||||
|
||||
return $path; |
||||
} |
||||
|
||||
public function getDirectoryPathByUri(string $uri = ''): ?string |
||||
{ |
||||
$path = rtrim( |
||||
sprintf( |
||||
'%s/pages/%s', |
||||
$this->_path, |
||||
str_replace( |
||||
':', |
||||
'/', |
||||
mb_strtolower( |
||||
urldecode( |
||||
$uri |
||||
) |
||||
) |
||||
) |
||||
), |
||||
'/' |
||||
); |
||||
|
||||
if (!isset($this->_tree[$path]) || !is_dir($path) || !is_readable($path)) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
return $path; |
||||
} |
||||
|
||||
public function getDirectoryUriByPath(string $path): ?string |
||||
{ |
||||
if (!isset($this->_tree[$path]) || !is_dir($path) || !is_readable($path)) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
$path = str_replace( |
||||
sprintf( |
||||
'%s/pages', |
||||
$this->_path |
||||
), |
||||
'', |
||||
$path |
||||
); |
||||
|
||||
$path = trim( |
||||
$path, |
||||
'/' |
||||
); |
||||
|
||||
$path = str_replace( |
||||
[ |
||||
'/' |
||||
], |
||||
[ |
||||
':' |
||||
], |
||||
$path |
||||
); |
||||
|
||||
return $path; |
||||
} |
||||
|
||||
public function getMediaPathByUri(string $uri): ?string |
||||
{ |
||||
$path = sprintf( |
||||
'%s/media/%s', |
||||
$this->_path, |
||||
str_replace( |
||||
':', |
||||
'/', |
||||
mb_strtolower( |
||||
urldecode( |
||||
$uri |
||||
) |
||||
) |
||||
) |
||||
); |
||||
|
||||
if (!$this->isPath($path)) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
return $path; |
||||
} |
||||
|
||||
public function getMimeByPath(?string $path): ?string |
||||
{ |
||||
if ($this->isPath($path)) |
||||
{ |
||||
if ($mime = mime_content_type($path)) |
||||
{ |
||||
return $mime; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
public function getDataByPath(?string $path): ?string |
||||
{ |
||||
if ($this->isPath($path)) |
||||
{ |
||||
if ($data = file_get_contents($path)) |
||||
{ |
||||
return $data; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
public function isPath(?string $path): bool |
||||
{ |
||||
if (in_array($path, $this->_list) && is_file($path) && is_readable($path)) |
||||
{ |
||||
return true; |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
private function _index( |
||||
string $path, |
||||
?array $blacklist = ['sidebar.txt', '__template.txt'] |
||||
): void |
||||
{ |
||||
foreach ((array) scandir($path) as $file) |
||||
{ |
||||
if (str_starts_with($file, '.')) |
||||
{ |
||||
continue; |
||||
} |
||||
|
||||
if (is_link($file)) |
||||
{ |
||||
continue; |
||||
} |
||||
|
||||
if (in_array($file, $blacklist)) |
||||
{ |
||||
continue; |
||||
} |
||||
|
||||
$file = sprintf( |
||||
'%s/%s', |
||||
$path, |
||||
$file |
||||
); |
||||
|
||||
switch (true) |
||||
{ |
||||
case is_dir($file): |
||||
|
||||
if (!isset($this->_tree[$path])) |
||||
{ |
||||
$this->_tree[$path] = []; |
||||
} |
||||
|
||||
$this->_index($file); |
||||
|
||||
break; |
||||
|
||||
case is_file($file): |
||||
|
||||
$this->_tree[$path][] = $file; |
||||
|
||||
$this->_list[] = $file; |
||||
|
||||
break; |
||||
} |
||||
} |
||||
} |
||||
} |
@ -1,154 +0,0 @@
@@ -1,154 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Yggverse\Gemini\Dokuwiki; |
||||
|
||||
class Helper |
||||
{ |
||||
private \Yggverse\Gemini\Dokuwiki\Filesystem $_filesystem; |
||||
private \Yggverse\Gemini\Dokuwiki\Reader $_reader; |
||||
|
||||
public function __construct( |
||||
\Yggverse\Gemini\Dokuwiki\Filesystem $filesystem, |
||||
\Yggverse\Gemini\Dokuwiki\Reader $reader |
||||
) { |
||||
$this->_filesystem = $filesystem; |
||||
$this->_reader = $reader; |
||||
} |
||||
|
||||
public function getChildrenSectionLinksByUri(?string $uri = ''): array |
||||
{ |
||||
$sections = []; |
||||
|
||||
if ($directory = $this->_filesystem->getDirectoryPathByUri($uri)) |
||||
{ |
||||
foreach ((array) $this->_filesystem->getTree() as $path => $files) |
||||
{ |
||||
if (str_starts_with($path, $directory) && $path != $directory) |
||||
{ |
||||
// Init link name |
||||
$h1 = null; |
||||
|
||||
// Init this directory URI |
||||
$thisUri = $this->_filesystem->getDirectoryUriByPath( |
||||
$path |
||||
); |
||||
|
||||
// Skip sections deeper this level |
||||
if (substr_count($thisUri, ':') > ($uri ? substr_count($uri, ':') + 1 : 0)) |
||||
{ |
||||
continue; |
||||
} |
||||
|
||||
// Get section names |
||||
$segments = []; |
||||
|
||||
foreach ((array) explode(':', $thisUri) as $segment) |
||||
{ |
||||
$segments[] = $segment; |
||||
|
||||
// Find section index if exists |
||||
if ($file = $this->_filesystem->getPagePathByUri(implode(':', $segments) . ':' . $segment)) |
||||
{ |
||||
$h1 = $this->_reader->getH1( |
||||
$this->_reader->toGemini( |
||||
$this->_filesystem->getDataByPath( |
||||
$file |
||||
) |
||||
) |
||||
); |
||||
} |
||||
|
||||
// Find section page if exists |
||||
else if ($file = $this->_filesystem->getPagePathByUri(implode(':', $segments))) |
||||
{ |
||||
$h1 = $this->_reader->getH1( |
||||
$this->_reader->toGemini( |
||||
$this->_filesystem->getDataByPath( |
||||
$file |
||||
) |
||||
) |
||||
); |
||||
} |
||||
|
||||
// Reset title of undefined segment |
||||
else |
||||
{ |
||||
$h1 = null; |
||||
} |
||||
} |
||||
|
||||
// Register section link |
||||
$sections[] = sprintf( |
||||
'=> /%s %s', |
||||
$thisUri, |
||||
$h1 |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Keep unique |
||||
$sections = array_unique( |
||||
$sections |
||||
); |
||||
|
||||
// Sort asc |
||||
sort( |
||||
$sections |
||||
); |
||||
|
||||
return $sections; |
||||
} |
||||
|
||||
public function getChildrenPageLinksByUri(?string $uri = ''): array |
||||
{ |
||||
$pages = []; |
||||
|
||||
if ($directory = $this->_filesystem->getDirectoryPathByUri($uri)) |
||||
{ |
||||
foreach ((array) $this->_filesystem->getPagePathsByPath($directory) as $file) |
||||
{ |
||||
if ($link = $this->getPageLinkByPath($file)) |
||||
{ |
||||
$pages[] = $link; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Keep unique |
||||
$pages = array_unique( |
||||
$pages |
||||
); |
||||
|
||||
// Sort asc |
||||
sort( |
||||
$pages |
||||
); |
||||
|
||||
return $pages; |
||||
} |
||||
|
||||
public function getPageLinkByPath(string $path): ?string |
||||
{ |
||||
if (in_array($path, $this->_filesystem->getList()) && is_file($path) && is_readable($path)) |
||||
{ |
||||
return sprintf( |
||||
'=> /%s %s', |
||||
$this->_filesystem->getPageUriByPath( |
||||
$path |
||||
), |
||||
$this->_reader->getH1( |
||||
$this->_reader->toGemini( |
||||
$this->_filesystem->getDataByPath( |
||||
$path |
||||
) |
||||
) |
||||
) |
||||
); |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
} |
@ -1,412 +0,0 @@
@@ -1,412 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Yggverse\Gemini\Dokuwiki; |
||||
|
||||
use dekor\ArrayToTextTable; |
||||
|
||||
class Reader |
||||
{ |
||||
private array $_macros = |
||||
[ |
||||
'~URL:base~' => null, |
||||
'~IPv6:open~' => '[', |
||||
'~IPv6:close~' => ']', |
||||
'~LINE:break~' => PHP_EOL |
||||
]; |
||||
|
||||
private array $_rule = |
||||
[ |
||||
// Headers |
||||
'/^([\s]*)#([^#]+)/' => '$1#$2' . PHP_EOL, |
||||
'/^([\s]*)##([^#]+)/' => '$1##$2' . PHP_EOL, |
||||
'/^([\s]*)###([^#]+)/' => '$1###$2' . PHP_EOL, |
||||
'/^([\s]*)####([^#]+)/' => '$1###$2' . PHP_EOL, |
||||
'/^([\s]*)#####([^#]+)/' => '$1###$2' . PHP_EOL, |
||||
'/^([\s]*)######([^#]+)/' => '$1###$2' . PHP_EOL, |
||||
|
||||
'/^[\s]*[=]{6}([^=]+)[=]{6}/' => '# $1' . PHP_EOL, |
||||
'/^[\s]*[=]{5}([^=]+)[=]{5}/' => '## $1' . PHP_EOL, |
||||
'/^[\s]*[=]{4}([^=]+)[=]{4}/' => '### $1' . PHP_EOL, |
||||
'/^[\s]*[=]{3}([^=]+)[=]{3}/' => '### $1' . PHP_EOL, |
||||
'/^[\s]*[=]{2}([^=]+)[=]{2}/' => '### $1' . PHP_EOL, |
||||
'/^[\s]*[=]{1}([^=]+)[=]{1}/' => '### $1' . PHP_EOL, |
||||
|
||||
// Tags |
||||
'/\*\*/' => '', |
||||
'/\'\'/' => '', |
||||
'/\%\%/' => '', |
||||
'/(?<!:)\/\//' => '', |
||||
|
||||
// Remove extra spaces |
||||
'/(\s)\s+/' => '$1', |
||||
|
||||
// Links |
||||
|
||||
/// Detect IPv6 (used as no idea how to resolve square quotes in rules below) |
||||
'/\[\[([^\[]+)\[([A-f:0-9]*)\]([^\]]+)\]\]/' => '$1~IPv6:open~$2~IPv6:close~$3', |
||||
|
||||
/// Remove extra chars |
||||
'/\[\[\s*\:?([^\|]+)\s*\|\s*([^\]]+)\s*\]\]/' => '[[$1|$2]]', |
||||
'/\[\[\s*\:?([^\]]+)\s*\]\]/' => '[[$1]]', |
||||
|
||||
'/\{\{\s*\:?([^\|]+)\s*\|\s*([^\}]+)\s*\}\}/' => '{{$1|$2}}', |
||||
'/\{\{\s*\:?([^\}]+)\s*\}\}/' => '{{$1}}', |
||||
|
||||
/// Wikipedia |
||||
'/\[\[wp([A-z]{2,})>([^\|]+)\|([^\]]+)\]\]/ui' => '$3 ( https://$1.wikipedia.org/wiki/$2 )', |
||||
'/\[\[wp>([^\|]+)\|([^\]]+)\]\]/i' => '$2 ( https://en.wikipedia.org/wiki/$1 )', |
||||
'/\[\[wp([A-z]{2,})>([^\]]+)\]\]/i' => '$2 ( https://$1.wikipedia.org/wiki/$2 )', |
||||
'/\[\[wp>([^\]]+)\]\]/i' => '$1 ( https://en.wikipedia.org/wiki/$1 )', |
||||
|
||||
/// Dokuwiki |
||||
'/\[\[doku>([^\|]+)\|([^\]]+)\]\]/i' => '$2( https://www.dokuwiki.org/$1 )', |
||||
'/\[\[doku>([^\]]+)\]\]/i' => '$1( https://www.dokuwiki.org/$1 )', |
||||
|
||||
/// Index |
||||
/// Useful with src/Dokuwiki/Helper.php |
||||
'/\{\{indexmenu>:([^\}]+)\}\}/i' => '', |
||||
'/\{\{indexmenu_n>[\d]+\}\}/i' => '', |
||||
|
||||
// Related |
||||
'/\[\[this>([^\|]+)\|([^\]]+)\]\]/i' => '$2', |
||||
|
||||
/// Relative |
||||
'/\[\[(?!https?:|this|doku|wp[A-z]{0,2})([^\|]+)\|([^\]]+)\]\]/i' => ' $2$3 ( ~URL:base~$1 )', |
||||
'/\[\[(?!https?:|this|doku|wp[A-z]{0,2})([^\]]+)\]\]/i' => ' $2 ( ~URL:base~$1 )', |
||||
|
||||
/// Absolute |
||||
'/\[\[(https?:)([^\|]+)\|([^\]]+)\]\]/i' => '$3 ( $1$2 )', |
||||
'/\[\[(https?:)([^\]]+)\]\]/i' => '$1$2', // @TODO |
||||
|
||||
/// Media |
||||
'/\{\{(?!https?:)([^\|]+)\|([^\}]+)\}\}/i' => PHP_EOL . '=> /$1$2' . PHP_EOL, |
||||
'/\{\{(?!https?:)([^\}]+)\}\}/i' => PHP_EOL . '=> /$1$2' . PHP_EOL, |
||||
|
||||
// List |
||||
'/^[\s]?-/' => '* ', |
||||
'/^[\s]+\*/' => '*', |
||||
|
||||
// Separators |
||||
'/[\\\]{2}/' => '~LINE:break~', |
||||
|
||||
// Plugins |
||||
'/~~DISCUSSION~~/' => '', // @TODO |
||||
'/~~INFO:syntaxplugins~~/' => '', // @TODO |
||||
|
||||
// Final corrections |
||||
'/[\n\r]+[.,;:]+/' => PHP_EOL |
||||
]; |
||||
|
||||
public function __construct(?array $rules = null) |
||||
{ |
||||
if ($rules) |
||||
{ |
||||
$this->_rule = $rules; |
||||
} |
||||
} |
||||
|
||||
// Macros operations |
||||
public function getMacroses(): array |
||||
{ |
||||
$this->_macros; |
||||
} |
||||
|
||||
public function setMacroses(array $macros) |
||||
{ |
||||
$this->_macros = $macros; |
||||
} |
||||
|
||||
public function getMacros(string $key, string $value): ?string |
||||
{ |
||||
$this->_macros[$key] = isset($this->_macros[$key]) ? $value : null; |
||||
} |
||||
|
||||
public function setMacros(string $key, ?string $value): void |
||||
{ |
||||
if ($value) |
||||
{ |
||||
$this->_macros[$key] = $value; |
||||
} |
||||
|
||||
else |
||||
{ |
||||
unset( |
||||
$this->_macros[$key] |
||||
); |
||||
} |
||||
} |
||||
|
||||
// Rule operations |
||||
public function getRules(): array |
||||
{ |
||||
$this->_rule; |
||||
} |
||||
|
||||
public function setRules(array $rules) |
||||
{ |
||||
$this->_rule = $rules; |
||||
} |
||||
|
||||
public function getRule(string $key, string $value): ?string |
||||
{ |
||||
$this->_rule[$key] = isset($this->_rule[$key]) ? $value : null; |
||||
} |
||||
|
||||
public function setRule(string $key, ?string $value): void |
||||
{ |
||||
if ($value) |
||||
{ |
||||
$this->_rule[$key] = $value; |
||||
} |
||||
|
||||
else |
||||
{ |
||||
unset( |
||||
$this->_rule[$key] |
||||
); |
||||
} |
||||
} |
||||
|
||||
// Convert DokuWiki text to Gemini |
||||
public function toGemini(?string $data, ?array &$lines = []): ?string |
||||
{ |
||||
if (empty($data)) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
$raw = false; |
||||
|
||||
$lines = []; |
||||
|
||||
foreach ((array) explode(PHP_EOL, $data) as $line) |
||||
{ |
||||
// Skip any formatting in lines between code tag |
||||
if (!$raw && preg_match('/<(code|file)([^>]*)>/i', $line, $matches)) |
||||
{ |
||||
// Prepend tag meta or filename as plain description |
||||
if (!empty($matches[0])) |
||||
{ |
||||
$lines[] = preg_replace( |
||||
'/<(code|file)[\s-]*([^>]*)>/i', |
||||
'$2', |
||||
$matches[0] |
||||
); |
||||
} |
||||
|
||||
$lines[] = '```'; |
||||
$lines[] = preg_replace( |
||||
'/<\/?(code|file)[^>]*>/i', |
||||
'', |
||||
$line |
||||
); |
||||
|
||||
$raw = true; |
||||
|
||||
// Make sure inline tag closed |
||||
if (preg_match('/<\/(code|file)>/i', $line)) |
||||
{ |
||||
$lines[] = '```'; |
||||
|
||||
$raw = false; |
||||
|
||||
continue; |
||||
} |
||||
|
||||
continue; |
||||
} |
||||
|
||||
if ($raw && preg_match('/<\/(code|file)>/i', $line)) |
||||
{ |
||||
$lines[] = preg_replace( |
||||
'/<\/(code|file)>/i', |
||||
'', |
||||
$line |
||||
); |
||||
|
||||
$lines[] = '```'; |
||||
|
||||
$raw = false; |
||||
|
||||
continue; |
||||
} |
||||
|
||||
if ($raw) |
||||
{ |
||||
$lines[] = preg_replace( |
||||
'/^```/', |
||||
' ```', |
||||
$line |
||||
); |
||||
|
||||
continue; |
||||
} |
||||
|
||||
// Apply config |
||||
$lines[] = preg_replace( |
||||
array_keys( |
||||
$this->_rule |
||||
), |
||||
array_values( |
||||
$this->_rule |
||||
), |
||||
strip_tags( |
||||
$line |
||||
) |
||||
); |
||||
} |
||||
|
||||
// ASCII table |
||||
$table = false; |
||||
|
||||
$rows = []; |
||||
|
||||
$th = []; |
||||
|
||||
foreach ($lines as $index => $line) |
||||
{ |
||||
// Strip line breaks |
||||
$line = str_replace( |
||||
'~LINE:break~', |
||||
' ', |
||||
$line |
||||
); |
||||
|
||||
// Header |
||||
if (!$table && preg_match_all('/\^([^\^]+)/', $line, $matches)) |
||||
{ |
||||
if (!empty($matches[1])) |
||||
{ |
||||
$table = true; |
||||
|
||||
$rows = []; |
||||
|
||||
$th = []; |
||||
|
||||
foreach ($matches[1] as $value) |
||||
{ |
||||
$th[] = trim( |
||||
$value |
||||
); |
||||
} |
||||
|
||||
unset( |
||||
$lines[$index] |
||||
); |
||||
|
||||
continue; |
||||
} |
||||
} |
||||
|
||||
// Body |
||||
if ($table) |
||||
{ |
||||
$table = false; |
||||
|
||||
if (preg_match(sprintf('/%s\|/', str_repeat('\|(.*)', count($th))), $line, $matches)) |
||||
{ |
||||
if (count($matches) == count($th) + 1) |
||||
{ |
||||
$table = true; |
||||
|
||||
$row = []; |
||||
foreach ($th as $offset => $column) |
||||
{ |
||||
$row[$column] = trim( |
||||
$matches[$offset + 1] |
||||
); |
||||
} |
||||
|
||||
$rows[] = $row; |
||||
|
||||
unset( |
||||
$lines[$index] |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (!$table && $rows) |
||||
{ |
||||
$builder = new ArrayToTextTable( |
||||
$rows |
||||
); |
||||
|
||||
$lines[$index] = '```' . PHP_EOL . $builder->render() . PHP_EOL . '```'; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Merge lines |
||||
return preg_replace( |
||||
'/[\n\r]{2,}/', |
||||
PHP_EOL . PHP_EOL, |
||||
str_replace( |
||||
array_keys( |
||||
$this->_macros |
||||
), |
||||
array_values( |
||||
$this->_macros |
||||
), |
||||
implode( |
||||
PHP_EOL, |
||||
$lines |
||||
) |
||||
) |
||||
); |
||||
} |
||||
|
||||
public function getH1(?string $gemini, ?string $regex = '/^[\s]?#([^#]+)/'): ?string |
||||
{ |
||||
foreach ((array) explode(PHP_EOL, (string) $gemini) as $line) |
||||
{ |
||||
preg_match( |
||||
$regex, |
||||
$line, |
||||
$matches |
||||
); |
||||
|
||||
if (!empty($matches[1])) |
||||
{ |
||||
return trim( |
||||
$matches[1] |
||||
); |
||||
|
||||
break; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
public function getLinks(?string $gemini, ?string $regex = '/(https?|gemini):\/\/\S+/'): array |
||||
{ |
||||
$links = []; |
||||
|
||||
if (empty($gemini)) |
||||
{ |
||||
return $links; |
||||
} |
||||
|
||||
preg_match_all( |
||||
$regex, |
||||
$gemini, |
||||
$matches |
||||
); |
||||
|
||||
if (!empty($matches[0])) |
||||
{ |
||||
foreach ((array) $matches[0] as $link) |
||||
{ |
||||
$links[] = trim( |
||||
$link |
||||
); |
||||
} |
||||
} |
||||
|
||||
return array_unique( |
||||
$links |
||||
); |
||||
} |
||||
} |
Loading…
Reference in new issue