jsonedit/src/Editor/Editor.php

719 lines
24 KiB
PHP
Raw Normal View History

2024-10-01 18:46:03 +02:00
<?php
namespace NoccyLabs\JsonEdit\Editor;
use NoccyLabs\JsonEdit\List\TreeList;
use NoccyLabs\JsonEdit\Settings;
use NoccyLabs\JsonEdit\Terminal\Terminal;
use NoccyLabs\JsonEdit\Tree\ArrayNode;
use NoccyLabs\JsonEdit\Tree\CollapsibleNode;
use NoccyLabs\JsonEdit\Tree\ObjectNode;
use NoccyLabs\JsonEdit\Tree\Tree;
use NoccyLabs\JsonEdit\Tree\ValueNode;
2024-10-01 18:46:03 +02:00
use Symfony\Component\Yaml\Yaml;
class Editor
{
private Tree $document;
private TreeList $list;
private int $currentRow = 0;
private int $scrollOffset = 0;
private ?string $filename = "untitled.json";
private ?string $shortfilename = "untitled.json";
2024-10-01 22:51:58 +02:00
private bool $running = true;
2024-10-02 00:53:11 +02:00
private bool $modified = false;
2024-10-01 22:51:58 +02:00
/**
* Constructor
*
* @param Terminal $term
*/
2024-10-01 18:46:03 +02:00
public function __construct(private Terminal $term)
{
$this->document = new Tree();
// $this->document->load((object)[
// 'foo' => true,
// 'bar' => [
// 'a', 'b', 'c'
// ],
// 'what' => 42
// ]);
$this->list = new TreeList($this->document);
}
2024-10-01 22:51:58 +02:00
/**
* Read a file into the Tree and TreeList
*
* @param string $filename
* @return void
*/
2024-10-01 18:46:03 +02:00
public function loadFile(string $filename): void
{
$this->filename = $filename;
$this->shortfilename = basename($filename);
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
switch ($ext) {
case 'json':
$doc = json_decode(file_get_contents($filename));
break;
case 'yml':
case 'yaml':
$doc = Yaml::parseFile($filename);
break;
default:
throw new \RuntimeException("Unable to read file of type {$ext}");
}
$this->document->load($doc);
$this->list->parseTree();
}
2024-10-01 22:51:58 +02:00
/**
* Load a document from array/object
*
< * @param mixed $document
2024-10-01 22:51:58 +02:00
* @return void
*/
2024-10-01 18:46:03 +02:00
public function loadDocument(mixed $document): void
{
$this->document->load($document);
$this->list->parseTree();
}
2024-10-01 22:51:58 +02:00
/**
* Run the editor
*
* @return void
*/
2024-10-01 18:46:03 +02:00
public function run()
{
$this->list->parseTree();
$this->redrawEditor();
while ($this->running) {
[$w,$h] = $this->term->getSize();
$read = $this->term->readKey();
switch ($read) {
case 'k{UP}':
$this->currentRow = max(0, $this->currentRow - 1);
$this->redrawEditor();
break;
case 'k{DOWN}':
$this->currentRow = min(count($this->list) - 1, $this->currentRow + 1);
2024-10-01 18:46:03 +02:00
$this->redrawEditor();
break;
case 'k{PGUP}':
$this->currentRow = max(0, $this->currentRow - ($h - 3));
$this->redrawEditor();
break;
case 'k{PGDN}':
$this->currentRow = min(count($this->list), $this->currentRow + ($h - 3));
$this->redrawEditor();
break;
case 'E':
2024-10-01 22:51:58 +02:00
$this->doEditKey();
2024-10-01 18:46:03 +02:00
break;
case 'e':
2024-10-01 22:51:58 +02:00
$this->doEditValue();
2024-10-01 18:46:03 +02:00
break;
case '[':
break;
case 'D':
$node = $this->list->getNodeForIndex($this->currentRow);
$path = $this->list->getPathForIndex($this->currentRow);
$parentPath = dirname($path);
$deleteKey = basename($path);
$collNode = $this->list->getNodeForPath($parentPath);
if ($collNode instanceof ArrayNode) {
$collNode->removeIndex($deleteKey);
$this->list->parseTree();
$this->redrawEditor();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
} elseif ($collNode instanceof ObjectNode) {
$collNode->unset($deleteKey);
$this->list->parseTree();
$this->redrawEditor();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
} else {
$this->showMessage("\e[97;41mCan only delete from object, array");
2024-10-01 18:46:03 +02:00
}
break;
// FIXME make sure this clones; editing a clone updates original as well
// case 'd':
// $node = $this->list->getNodeForIndex($this->currentRow);
// $coll = $this->list->findNearestCollection($this->currentRow, true);
// if (!$coll)
// break;
// $collNode = $this->list->getNodeForIndex($coll);
// if ($collNode instanceof ArrayNode) {
// $collNode->append(clone $node);
// $this->list->parseTree();
// $this->redrawEditor();
// } else {
// $this->term->setCursor(1, $h);
// echo "\e[97;41mCan only duplicate in arrays, node={$this->currentRow}, coll={$coll}\e[K\e[0m";
// }
// break;
2024-10-01 22:31:51 +02:00
case 'I':
$menu = new Menu($this->term, [
'value' => "Insert Value",
'object' => "Insert Object{}",
'array' => "Insert Array[]",
], 'Insert');
$this->redrawInfoBar([ '^C' => 'Cancel', '↑/↓' => 'Select option', 'Enter' => 'Accept' ]);
$sel = $menu->display(0, 0, 30, 0, "value");
2024-10-01 22:31:51 +02:00
$this->redrawEditor();
switch ($sel) {
case 'value':
$this->doInsertValue();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:31:51 +02:00
break;
case 'array':
$this->doInsertValue('[]');
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:31:51 +02:00
break;
case 'object':
$this->doInsertValue('{}');
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
break;
}
2024-10-01 22:31:51 +02:00
break;
2024-10-01 18:46:03 +02:00
2024-10-02 01:46:23 +02:00
case 'H':
$this->doShowAbout();
break;
2024-10-02 00:53:11 +02:00
case 'h':
2024-10-02 01:46:23 +02:00
$this->doShowHelp();
2024-10-02 00:53:11 +02:00
break;
2024-10-01 22:31:51 +02:00
case 'i':
$this->doInsertValue();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
break;
case "\x18": // ctrl-x
2024-10-01 18:46:03 +02:00
$this->running = false;
break;
case "+":
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof CollapsibleNode) {
$node->collapse(!$node->isCollapsed());
$this->list->parseTree();
$this->redrawEditor();
}
break;
2024-10-02 00:53:11 +02:00
case "q":
Settings::$editorQuotedKeys = !Settings::$editorQuotedKeys;
$this->redrawEditor();
break;
2024-10-02 02:14:36 +02:00
case "c":
Settings::$compactGroups = !Settings::$compactGroups;
$this->list->parseTree();
$this->redrawEditor();
break;
2024-10-02 00:53:11 +02:00
case "\x0e": // ctrl-n
$this->document->load((object)[]);
$this->list->parseTree();
$this->filename = "untitled.json";
$this->shortfilename = "untitled.json";
$this->modified = false;
$this->redrawEditor();
break;
2024-10-01 21:40:13 +02:00
case "\x12": // ctrl-r
2024-10-01 22:51:58 +02:00
$this->doReadFile();
2024-10-01 21:40:13 +02:00
break;
case "\x17": // ctrl-w
2024-10-01 22:51:58 +02:00
$this->doWriteFile();
2024-10-01 21:40:13 +02:00
break;
2024-10-01 18:46:03 +02:00
case null:
break;
default:
$this->term->setCursor(1, $h, true);
2024-10-01 21:40:13 +02:00
echo sprintf("%s %02x %03d", ctype_print($read)?$read:'.', ord($read), ord($read));
2024-10-01 18:46:03 +02:00
sleep(1);
}
}
2024-10-02 00:53:11 +02:00
Settings::save(SETTINGS_FILE);
2024-10-01 18:46:03 +02:00
// for ($n = 0; $n < 20; $n++) {
// $this->currentRow = $n;
// $this->redrawEditor();
// sleep(1);
// }
}
2024-10-01 22:51:58 +02:00
/**
* Handler for read file command (ctrl-R)
*
* @return void
*/
private function doReadFile(): void
{
[$w,$h] = $this->term->getSize();
$readFrom = $this->ask("\e[33mRead from:\e[0m ", "");
if (!file_exists($readFrom)) {
$this->showMessage("\e[97;41mFile does not exist");
2024-10-01 23:01:26 +02:00
return;
2024-10-01 22:51:58 +02:00
}
if (!is_readable($readFrom)) {
$this->showMessage("\e[97;41mFile not readable");
2024-10-01 23:01:26 +02:00
return;
2024-10-01 22:51:58 +02:00
}
$ext = strtolower(pathinfo($readFrom, PATHINFO_EXTENSION));
switch ($ext) {
case 'json':
$doc = json_decode(file_get_contents($readFrom));
break;
case 'yml':
case 'yaml':
$doc = Yaml::parseFile($readFrom);
break;
default:
$this->showMessage("\e[97;41mUnable to read format: {$ext}");
2024-10-01 23:01:26 +02:00
return;
2024-10-01 22:51:58 +02:00
}
$this->filename = $readFrom;
$this->shortfilename = basename($readFrom);
$this->document->load($doc);
$this->currentRow = 0;
$this->list->parseTree();
$this->redrawEditor();
$this->showMessage("\e[97;42mLoaded {$readFrom}");
2024-10-01 22:51:58 +02:00
}
/**
* Handler for the write file command (ctrl-W)
*
* @return void
*/
private function doWriteFile(): void
{
[$w,$h] = $this->term->getSize();
$saveTo = $this->ask("\e[33mWrite to:\e[0m ", $this->filename);
$doc = $this->document->save();
$ext = strtolower(pathinfo($saveTo, PATHINFO_EXTENSION));
switch ($ext) {
case 'json':
file_put_contents($saveTo, json_encode($doc, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)."\n");
break;
case 'yml':
case 'yaml':
$doc = json_decode(json_encode($doc), true);
file_put_contents($saveTo, Yaml::dump($doc));
break;
default:
$this->showMessage("\e[97;41mUnable to write format: {$ext}");
2024-10-01 22:51:58 +02:00
}
2024-10-02 00:53:11 +02:00
$this->filename = $saveTo;
$this->shortfilename = basename($saveTo);
$this->modified = false;
$this->redrawEditor();
$this->showMessage("\e[97;42mWrote to {$saveTo}");
2024-10-01 22:51:58 +02:00
2024-10-02 00:53:11 +02:00
2024-10-01 22:51:58 +02:00
}
2024-10-02 01:46:23 +02:00
private function doShowHelp(): void
{
[$w,$h] = $this->term->getSize();
$text = <<<EOT
Welcome to JSONEdit! The editor you have missed all this time without even knowing it!
# QuickStart
To get started, press I (shift-i) and add something to the document. Use the arrow keys to get around. To cancel a prompt, close a menu or close a dialog, press ctrl-C. When you are happy with your work, press ctrl-W and enter a filename to write. You can also press ctrl-R and read in a new file, overwriting your masterpiece.
## Useful keys
↑↓ Navigate values in document
h Show this help
H Show information about the app and license
i Insert a new value
I Insert value, array or object
e Edit selected value
E Edit selected key
D Delete selected key
2024-10-02 02:14:36 +02:00
c Toggle compact list view
q Toggle quoted keys in list view
2024-10-02 01:46:23 +02:00
^W Write to file
^R Read from file
^N New document with empty object
^C Cancel/Exit
## Doing stuff
### Adding keys or values
To add a key or a value, navigate to a value in an array or object, or to a specific array or object, and press "i". You will be prompted for the value, and for objects the key.
You can also press "I" to add arrays and objects. Just select what you want to add in the menu and press enter.
### Editing keys
You can edit keys on objects. For this, press "E".
### Editing values
2024-10-03 00:24:20 +02:00
To edit a value, press "e". The value is verbatim JSON, so strings should be quoted and all that. Anything that is unparsable JSON will be used as is, resulting in a string.
2024-10-02 01:46:23 +02:00
### Loading and Saving files
To load a file, press ^R and enter the filename to read. To write to a file, press ^W and enter the filename to write to.
### YAML or JSON?
There is no need to select YAML or JSON mode. All operations work the same, and the format is determined on load or save.
2024-10-02 01:46:23 +02:00
# Disclaimer
This is beta software, if not alpha. It kinda works, but there will be issues. Feel free to help out with a patch, or by filing bug reports.
Known issues include:
* Editing long lines will blow up. Don't try to edit anything longer than the terminal is wide.
* There is no fullscreen editing, so verbatim blocks in twig will probably not work well either.
* Comments are not preserved.
* Files are overwritten without confirmation.
* There is no command mode, no search.
* Some things just don't work yet.
* Unhandled keys will appear in the bottom left of the screen with a delay.
* Folding is not yet implemented.
* There are crashes, and lock-ups. Data corruption is a possibility.
# Support
Go to https://dev.noccylabs.info/noccy/jsonedit to find the source code, issue tracker, and learn more about the project!
EOT;
$width = min(90, $w - 10);
$height = min(40, $h - 6);
$left = round(($w / 2) - ($width / 2));
$top = round(($h / 2) - ($height / 2));
$msg = new MessageBox($this->term, $text, "Help");
$this->redrawInfoBar([ '↑/↓' => 'Scroll', '^C' => 'Close' ]);
$msg->display($left, $top, $width, $height);
$this->redrawEditor();
}
private function doShowAbout(): void
{
[$w,$h] = $this->term->getSize();
$text = <<<EOT
## JSONEdit Alpha - Copyright (C) 2024, NoccyLabs
2024-10-02 01:46:23 +02:00
Licensed under GNU GPL v3.0 or later.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
EOT;
$width = 60;
$height = 20;
$left = round(($w / 2) - ($width / 2));
$top = round(($h / 2) - ($height / 2));
$msg = new MessageBox($this->term, $text, "About JSONEdit");
$this->redrawInfoBar([ '↑/↓' => 'Scroll', '^C' => 'Close' ]);
$msg->display($left, $top, $width, $height);
$this->redrawEditor();
}
2024-10-01 22:51:58 +02:00
/**
* Edit selected key
*
* @return void
*/
private function doEditKey(): void
{
[$w,$h] = $this->term->getSize();
//$coll = $this->list->findNearestCollection($this->currentRow);
$entry = $this->list->getEntryForIndex($this->currentRow);
$path = $this->list->getPathForIndex($this->currentRow);
$parentIndex = $this->list->getIndexForPath(dirname($path));
$parent = $this->list->getEntryForIndex($parentIndex);
if ($parent->node instanceof ObjectNode) {
$newVal = $this->ask("\e[0;33mnew key:\e[0m ", $entry->key);
if (!empty($newVal)) {
2024-10-02 00:53:11 +02:00
$parent->node->rename($entry->key, $newVal);
2024-10-01 22:51:58 +02:00
$entry->key = $newVal;
2024-10-02 00:53:11 +02:00
$this->list->parseTree();
2024-10-01 22:51:58 +02:00
$this->redrawEditor();
}
} else {
// $this->term->setCursor(1, $h);
// echo "\e[97;41mCan only edit keys on objects\e[K\e[0m";
$this->showMessage("\e[97;41mCan only edit keys on objects");
2024-10-01 22:51:58 +02:00
}
}
/**
* Edit selected value
*
* @return void
*/
private function doEditValue(): void
{
[$w,$h] = $this->term->getSize();
//$coll = $this->list->findNearestCollection($this->currentRow);
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof ValueNode) {
$val = json_encode($node->value, JSON_UNESCAPED_SLASHES);
$newVal = $this->ask("\e[33;1mnew value:\e[0m ", $val);
if ($newVal !== null) {
$val = json_decode($newVal);
// If the string decodes to null, but isn't 'null', treat it as a string
if ($val === null && $newVal !== 'null') {
$val = $newVal;
}
$node->value = $val;
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:51:58 +02:00
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
} else {
$this->showMessage("\e[97;41mCan not edit array/object");
2024-10-01 22:51:58 +02:00
}
}
/**
* Insert a new value
*
* @param mixed $value
* @return void
*/
2024-10-01 22:31:51 +02:00
private function doInsertValue(mixed $value = null): void
{
$coll = $this->list->findNearestCollection($this->currentRow);
$node = $this->list->getNodeForIndex($coll);
if ($node instanceof ObjectNode) {
$key = $this->ask("\e[0;33mkey:\e[0m ");
if (empty($key)) {
2024-10-01 22:31:51 +02:00
$this->redrawInfoBar();
return;
}
}
if ($value === null)
$value = $this->ask("\e[0;32mvalue:\e[0m ");
2024-10-01 22:31:51 +02:00
if ($value !== null) {
$newvalue = json_decode($value);
// If the string decodes to null, but isn't 'null', treat it as a string
if ($newvalue === null && $value !== 'null') {
$newvalue = $value;
}
$value = $newvalue;
2024-10-01 22:31:51 +02:00
$valueNode = match (true) {
is_array($value) => new ArrayNode([]),
is_object($value) => new ObjectNode([]),
default => new ValueNode($value)
};
if ($node instanceof ArrayNode) {
$node->append($valueNode);
} elseif ($node instanceof ObjectNode) {
$node->set($key, $valueNode);
}
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:31:51 +02:00
$this->list->parseTree();
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
}
2024-10-01 22:51:58 +02:00
/**
* Ask for input
*
* @param string $prompt
* @param string $value
* @return string|null
*/
2024-10-01 18:46:03 +02:00
public function ask(string $prompt, $value = ''): ?string
{
$plainPrompt = preg_replace('<\e\[.+?m>', '', $prompt);
$promptLen = mb_strlen($plainPrompt);
[$w,$h] = $this->term->getSize();
$prompting = true;
$cursorPos = mb_strlen($value);
while ($prompting) {
$this->term->setCursor(1, $h);
echo $prompt."\e[0m\e[K".$value;
$this->term->setCursor($promptLen+$cursorPos+1, $h, true);
while (null === ($ch = $this->term->readKey())) {
usleep(10000);
}
if (mb_strlen($ch) == 1) {
if (ord($ch) < 32) {
if (ord($ch) == 13) {
$this->term->setCursor(1, $h);
echo "\e[0m\e[K";
return $value;
}
if (ord($ch) == 3) {
2024-10-01 21:40:13 +02:00
// ctrl-c
2024-10-01 18:46:03 +02:00
$this->term->setCursor(1, $h);
echo "\e[0m\e[K";
return null;
2024-10-01 21:40:13 +02:00
}
2024-10-01 18:46:03 +02:00
} elseif (ord($ch) == 127) {
if ($cursorPos > 0) {
$value = substr($value, 0, $cursorPos - 1) . substr($value, $cursorPos);
$cursorPos--;
}
} else {
$value = mb_substr($value, 0, $cursorPos) . $ch . mb_substr($value, $cursorPos);
$cursorPos++;
}
} else {
switch ($ch) {
case "k{LEFT}":
if ($cursorPos > 0)
$cursorPos--;
break;
case "k{RIGHT}":
if ($cursorPos < mb_strlen($value))
$cursorPos++;
break;
}
}
}
}
2024-10-01 22:51:58 +02:00
/**
* Redraw the editor
*
* @return void
*/
2024-10-01 18:46:03 +02:00
public function redrawEditor()
{
[$w,$h] = $this->term->getSize();
while ($this->currentRow < $this->scrollOffset) $this->scrollOffset--;
while ($this->currentRow > $h + $this->scrollOffset - 3) $this->scrollOffset++;
$path = $this->list->getPathForIndex($this->currentRow);
$node = $this->list->getNodeForIndex($this->currentRow);
2024-10-02 00:53:11 +02:00
$modified = $this->modified ? "\e[31m*\e[37m" : "";
2024-10-01 18:46:03 +02:00
$this->term->setCursor(1, $h-1);
2024-10-02 00:53:11 +02:00
echo "\e[40;37m\e[K{$modified}\e[1m{$this->shortfilename}\e[22m#\e[3m{$path}\e[37;23m";
2024-10-01 18:46:03 +02:00
//$this->term->setCursor(1, $h);
echo " = ";
if ($node instanceof ValueNode) {
echo json_encode($node->value, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
} elseif ($node instanceof ArrayNode) {
echo "[…]";
} elseif ($node instanceof ObjectNode) {
echo "{…}";
}
echo "\e[0m";
$this->redrawInfoBar();
// $this->term->setCursor(1, $h);
// echo "\e[0;37m\e[K";
for ($n = 0; $n < $h - 2; $n++) {
$this->list->drawEntry($n + 1, $n + $this->scrollOffset, $w, ($n + $this->scrollOffset == $this->currentRow));
}
// $this->term->setCursor(1, 1);
// echo "\e[90;3m«Empty»\e[0m";
}
2024-10-01 22:51:58 +02:00
/**
* Redraw the info bar
*
* @return void
*/
private function redrawInfoBar(?array $keys = null)
2024-10-01 18:46:03 +02:00
{
[$w,$h] = $this->term->getSize();
if (!$keys) {
$keys = [
'h' => 'Help',
'D' => 'Delete',
];
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof ValueNode) {
$keys = [ ...$keys,
'e' => 'Edit',
];
}
if ($node instanceof ObjectNode || $node instanceof ArrayNode) {
$keys = [ ...$keys,
'I' => 'Insert…',
];
}
if ($this->modified) {
$keys = [ ...$keys,
'^N' => 'New',
'^W' => 'Write',
];
}
$keys = [ ...$keys,
'^R' => 'Read',
'^X' => 'Exit',
];
}
2024-10-01 18:46:03 +02:00
$this->term->setCursor(1, $h);
echo "\e[0;40m\e[K";
2024-10-01 18:46:03 +02:00
foreach ($keys as $key=>$info)
echo "\e[37;40;2m\u{e0b6}\e[7;37m{$key} \e[22m {$info} \e[27m\u{e0b4}\e[0m";
//echo " \e[1m{$key}\e[2m \e[3m{$info}\e[0m \e[90m\u{2502}\e[0m";
2024-10-01 18:46:03 +02:00
}
private function showMessage(string $message): void
{
[$w,$h] = $this->term->getSize();
$this->term->setCursor(1, $h);
echo $message."\e[K\e[0m";
}
2024-10-01 18:46:03 +02:00
}