From 0718de62d4963d8ffe731cd7141db0b74e6503ce Mon Sep 17 00:00:00 2001
From: Robin Appelman <robin@icewind.nl>
Date: Fri, 16 Jun 2023 18:24:06 +0200
Subject: [PATCH 1/2] add option for raw output in log:watch/log:tail

Signed-off-by: Robin Appelman <robin@icewind.nl>
---
 lib/Command/Tail.php  | 56 ++++++++++++++++++++++++-------------------
 lib/Command/Watch.php | 17 ++++++++++---
 2 files changed, 45 insertions(+), 28 deletions(-)

diff --git a/lib/Command/Tail.php b/lib/Command/Tail.php
index 0f317e72..ab125a0e 100644
--- a/lib/Command/Tail.php
+++ b/lib/Command/Tail.php
@@ -29,7 +29,6 @@
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Input\StringInput;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
 use Symfony\Component\Console\Terminal;
@@ -51,42 +50,49 @@ protected function configure() {
 			->setName('log:tail')
 			->setDescription('Tail the nextcloud logfile')
 			->addArgument('lines', InputArgument::OPTIONAL, 'The number of log entries to print', "10")
-			->addOption('follow', 'f', InputOption::VALUE_NONE, 'Output new log entries as they appear');
+			->addOption('follow', 'f', InputOption::VALUE_NONE, 'Output new log entries as they appear')
+			->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item');
 		parent::configure();
 	}
 
 	protected function execute(InputInterface $input, OutputInterface $output): int {
+		$raw = $input->getOption('raw');
 		$count = (int)$input->getArgument('lines');
-		$terminal = new Terminal();
-		$totalWidth = $terminal->getWidth();
-		// 8 level, 18 for app, 26 for time, 6 for formatting
-		$messageWidth = $totalWidth - 8 - 18 - 26 - 6;
 		$io = new SymfonyStyle($input, $output);
 		$logIterator = $this->logIteratorFactory->getLogIterator('11111');
-		$i = 0;
-		$tableItems = [];
-		foreach ($logIterator as $logItem) {
-			$i++;
-			if ($i > $count) {
-				break;
+		$logIterator = new \LimitIterator($logIterator, 0, $count);
+		$logItems = iterator_to_array($logIterator);
+		$logItems = array_reverse($logItems);
+
+		if ($raw) {
+			foreach ($logItems as $logItem) {
+				$output->writeln(json_encode($logItem));
 			}
-			$tableItems[] = [
-				self::LEVELS[$logItem['level']],
-				wordwrap($logItem['app'], 18),
-				$this->formatter->formatMessage($logItem, $messageWidth) . "\n",
-				$logItem['time']
-			];
+		} else {
+			$terminal = new Terminal();
+			$totalWidth = $terminal->getWidth();
+			// 8 level, 18 for app, 26 for time, 6 for formatting
+			$messageWidth = $totalWidth - 8 - 18 - 26 - 6;
+
+			$tableItems = array_map(function (array $logItem) use ($messageWidth) {
+				return [
+					self::LEVELS[$logItem['level']],
+					wordwrap($logItem['app'], 18),
+					$this->formatter->formatMessage($logItem, $messageWidth) . "\n",
+					$logItem['time'],
+				];
+			}, $logItems);
+			$io->table([
+				'Level',
+				'App',
+				'Message',
+				'Time',
+			], $tableItems);
 		}
-		$io->table([
-			'Level',
-			'App',
-			'Message',
-			'Time'
-		], array_reverse($tableItems));
 
 		if ($input->getOption('follow')) {
 			$watch = new Watch($this->formatter, $this->logIteratorFactory);
-			$watch->run(new StringInput(''), $output);
+			$watch->watch($raw, $output);
 		}
 
 		return 0;
diff --git a/lib/Command/Watch.php b/lib/Command/Watch.php
index 2b5048d3..b9e3817c 100644
--- a/lib/Command/Watch.php
+++ b/lib/Command/Watch.php
@@ -28,6 +28,7 @@
 use OCA\LogReader\Log\Formatter;
 use OCA\LogReader\Log\LogIteratorFactory;
 use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Terminal;
 
@@ -47,7 +48,8 @@ public function __construct(Formatter $formatter, LogIteratorFactory $logIterato
 	protected function configure() {
 		$this
 			->setName('log:watch')
-			->setDescription('Watch the nextcloud logfile');
+			->setDescription('Watch the nextcloud logfile')
+			->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item');
 		parent::configure();
 	}
 
@@ -60,6 +62,11 @@ private function getLastLogId() {
 	}
 
 	protected function execute(InputInterface $input, OutputInterface $output): int {
+		$raw = $input->getOption('raw');
+		return $this->watch($raw, $output);
+	}
+
+	public function watch(bool $raw, OutputInterface $output): int {
 		$terminal = new Terminal();
 		$totalWidth = $terminal->getWidth();
 		// 8 level, 18 for app, 26 for time, 6 for formatting
@@ -97,8 +104,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
 				array_reverse($lines);
 
 				foreach ($lines as $line) {
-					$this->printItem($line, $output, $messageWidth);
-					$output->writeln("");
+					if ($raw) {
+						$output->writeln(json_encode($line));
+					} else {
+						$this->printItem($line, $output, $messageWidth);
+						$output->writeln("");
+					}
 				}
 
 				$lastId = $id;

From a66c992215ac738692796823d125db211636ee0c Mon Sep 17 00:00:00 2001
From: Robin Appelman <robin@icewind.nl>
Date: Fri, 16 Jun 2023 19:37:05 +0200
Subject: [PATCH 2/2] add option to filter log:tail/log:watch output

Signed-off-by: Robin Appelman <robin@icewind.nl>
---
 lib/Command/Tail.php    |   8 ++-
 lib/Command/Watch.php   |  11 ++--
 lib/Log/FilterQuery.php | 137 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 151 insertions(+), 5 deletions(-)
 create mode 100644 lib/Log/FilterQuery.php

diff --git a/lib/Command/Tail.php b/lib/Command/Tail.php
index ab125a0e..1bad6596 100644
--- a/lib/Command/Tail.php
+++ b/lib/Command/Tail.php
@@ -24,6 +24,7 @@
 namespace OCA\LogReader\Command;
 
 use OC\Core\Command\Base;
+use OCA\LogReader\Log\FilterQuery;
 use OCA\LogReader\Log\Formatter;
 use OCA\LogReader\Log\LogIteratorFactory;
 use Symfony\Component\Console\Input\InputArgument;
@@ -51,7 +52,8 @@ protected function configure() {
 			->setDescription('Tail the nextcloud logfile')
 			->addArgument('lines', InputArgument::OPTIONAL, 'The number of log entries to print', "10")
 			->addOption('follow', 'f', InputOption::VALUE_NONE, 'Output new log entries as they appear')
-			->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item');
+			->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item')
+			->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter log items according to the provided query');
 		parent::configure();
 	}
 
@@ -60,6 +62,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
 		$count = (int)$input->getArgument('lines');
 		$io = new SymfonyStyle($input, $output);
 		$logIterator = $this->logIteratorFactory->getLogIterator('11111');
+		$filter = new FilterQuery((string)$input->getOption('filter'));
+		$logIterator = new \CallbackFilterIterator($logIterator, function(array $item) use ($filter) {
+			return $filter->matches($item);
+		});
 		$logIterator = new \LimitIterator($logIterator, 0, $count);
 		$logItems = iterator_to_array($logIterator);
 		$logItems = array_reverse($logItems);
diff --git a/lib/Command/Watch.php b/lib/Command/Watch.php
index b9e3817c..06cb4a61 100644
--- a/lib/Command/Watch.php
+++ b/lib/Command/Watch.php
@@ -25,6 +25,7 @@
 
 use OC\Core\Command\Base;
 use OC\Core\Command\InterruptedException;
+use OCA\LogReader\Log\FilterQuery;
 use OCA\LogReader\Log\Formatter;
 use OCA\LogReader\Log\LogIteratorFactory;
 use Symfony\Component\Console\Input\InputInterface;
@@ -49,7 +50,8 @@ protected function configure() {
 		$this
 			->setName('log:watch')
 			->setDescription('Watch the nextcloud logfile')
-			->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item');
+			->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item')
+			->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter log items according to the provided query');
 		parent::configure();
 	}
 
@@ -63,10 +65,11 @@ private function getLastLogId() {
 
 	protected function execute(InputInterface $input, OutputInterface $output): int {
 		$raw = $input->getOption('raw');
-		return $this->watch($raw, $output);
+		$filter = new FilterQuery((string)$input->getOption('filter'));
+		return $this->watch($raw, $filter, $output);
 	}
 
-	public function watch(bool $raw, OutputInterface $output): int {
+	public function watch(bool $raw, FilterQuery $filter, OutputInterface $output): int {
 		$terminal = new Terminal();
 		$totalWidth = $terminal->getWidth();
 		// 8 level, 18 for app, 26 for time, 6 for formatting
@@ -95,7 +98,7 @@ public function watch(bool $raw, OutputInterface $output): int {
 						break;
 					}
 
-					if (!is_null($line)) {
+					if (!is_null($line) && $filter->matches($line)) {
 						$lines[] = $line;
 					}
 					$iterator->next();
diff --git a/lib/Log/FilterQuery.php b/lib/Log/FilterQuery.php
new file mode 100644
index 00000000..38e62add
--- /dev/null
+++ b/lib/Log/FilterQuery.php
@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2023 Robin Appelman <robin@icewind.nl>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\LogReader\Log;
+
+class FilterQuery {
+	const CMP_EQ = 1;
+	const CMP_LT = 2;
+	const CMP_GT = 3;
+	const CMP_LET = 4;
+	const CMP_GET = 5;
+	const CMP_NEQ = 6;
+
+	const COMPARISONS = [
+		self::CMP_NEQ => '!=',
+		self::CMP_LET => '<=',
+		self::CMP_GET => '>=',
+		self::CMP_EQ => '=',
+		self::CMP_LT => '<',
+		self::CMP_GT => '>',
+	];
+
+	/**
+	 * @var array{field: string, cmp: int, value: string}[]
+	 */
+	private array $comparisons;
+
+	public function __construct(string $query) {
+		$parts = array_filter(str_getcsv(str_replace("'", '"', $query), ' '));
+		$this->comparisons = array_map([$this, 'parsePart'], $parts);
+	}
+
+	/**
+	 * @param string $part
+	 * @return array{field: string, cmp: int, value: string}
+	 */
+	private function parsePart(string $part): array {
+		$field = 'message';
+		$value = $part;
+		$cmp = self::CMP_EQ;
+		foreach (self::COMPARISONS as $cmpVal => $cmpStr) {
+			if (str_contains($part, $cmpStr)) {
+				[$field, $value] = explode($cmpStr, $part);
+				switch ($field) {
+					case "level":
+						$value = $this->parseLogLevel($value);
+						break;
+					case "time":
+						$value = new \DateTimeImmutable($value);
+						break;
+				}
+				$cmp = $cmpVal;
+				break;
+			}
+		}
+		return [
+			'field' => $field,
+			'cmp' => $cmp,
+			'value' => $value,
+		];
+	}
+
+	public function matches(array $logItem): bool {
+		foreach ($this->comparisons as $comparison) {
+			$logValue = $logItem[$comparison['field']] ?? $logItem['data'][$comparison['field']] ?? null;
+			if (!$this->compare($logValue, $comparison['value'], $comparison['cmp'])) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	private function compare($a, $b, int $cmp): bool {
+		switch ($cmp) {
+			case self::CMP_EQ:
+				if (is_string($a)) {
+					return str_contains($a, $b);
+				} else {
+					return $a == $b;
+				}
+			case self::CMP_LT:
+				return $a < $b;
+			case self::CMP_GT:
+				return $a > $b;
+			case self::CMP_LET:
+				return $a <= $b;
+			case self::CMP_GET:
+				return $a >= $b;
+			case self::CMP_NEQ:
+				return !$this->compare($a, $b, self::CMP_EQ);
+			default:
+				return false;
+		}
+	}
+
+	private static function parseLogLevel(string $level): int {
+		if (is_numeric($level)) {
+			return (int)$level;
+		}
+
+		switch (strtoupper($level)) {
+			case "DEBUG":
+				return 0;
+			case "INFO":
+				return 1;
+			case "WARN":
+			case "WARNING":
+				return 2;
+			case "ERROR":
+				return 3;
+			case "FATAL":
+				return 4;
+			default:
+				throw new \Exception("Unknown log level $level");
+		}
+	}
+}