add kirby-loop plugin with French translations
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-23 21:41:50 +01:00
parent 8ea5f0c462
commit ab7fd8b2ea
74 changed files with 16423 additions and 2 deletions

View file

@ -0,0 +1,199 @@
<?php
namespace Moinframe\Loop;
use Kirby\Cms\Page;
use Kirby\Cms\App as KirbyApp;
use Moinframe\Loop\Models\Comment;
use Moinframe\Loop\Models\Reply;
class App
{
private static ?Database $instance = null;
/**
* Gets the major version of Kirby
* @return int Major version number
*/
public static function getKirbyMajorVersion(): int
{
$version = KirbyApp::version() ?? '0.0.0';
$parts = explode('.', $version);
return (int) ($parts[0] ?? 0);
}
/**
* Renders the loop component HTML
* @return string Component HTML
*/
public static function render(): string
{
$user = kirby()->user();
if (null === $user) {
return '';
}
return '';
}
/**
* Gets the database instance (singleton)
* @return Database
*/
protected static function db(): Database
{
if (self::$instance === null) {
self::$instance = new Database();
}
return self::$instance;
}
/**
* Converts array data to Comment objects
* @param array<mixed> $commentsData
* @return Comment[]
*/
private static function convertToCommentObjects(array $commentsData): array
{
$comments = [];
foreach ($commentsData as $commentData) {
$comment = Comment::fromArray($commentData);
$comments[] = $comment;
}
return $comments;
}
/**
* Get comments by kirby page
* @param Page $page
* @param string $lang Language code
* @return Comment[]
*/
public static function getCommentsByPage(\Kirby\Cms\Page $page, string $lang = ''): array
{
try {
// @phpstan-ignore method.notFound
$pageUuid = $page->content()->uuid()->value();
$db = self::db();
// Use optimized query that filters at database level
$commentsData = $db::getCommentsByPage($pageUuid, $lang)->toArray();
return self::convertToCommentObjects($commentsData);
} catch (\Exception $e) {
return [];
}
}
/**
* Retrieves all comments organized in a nested structure
* @return Comment[] array of Comment objects with nested replies
* @remarks Top-level comments have replies as children
*/
public static function getComments(): array
{
try {
$db = self::db();
// Use optimized query that fetches comments with replies in 2 queries instead of N+1
$commentsData = $db::getCommentsWithReplies()->toArray();
return self::convertToCommentObjects($commentsData);
} catch (\Exception $e) {
return [];
}
}
/**
* Creates a Comment from array data
* @param array<mixed> $data
* @return Comment|null The created comment or null if validation fails
*/
public static function createComment(array $data): ?Comment
{
try {
return Comment::fromArray($data);
} catch (\InvalidArgumentException $e) {
error_log(tt('moinframe.loop.comment.creation.failed', ['error' => $e->getMessage()]));
return null;
}
}
/**
* Creates a Reply from array data
* @param array<mixed> $data
* @return Reply|null The created reply or null if validation fails
*/
public static function createReply(array $data): ?Reply
{
try {
return Reply::fromArray($data);
} catch (\InvalidArgumentException $e) {
error_log(tt('moinframe.loop.reply.creation.failed', ['error' => $e->getMessage()]));
return null;
}
}
/**
* Adds a new comment to the database
* @param Comment $comment Comment to add
* @return Comment|null The added comment or null on failure
*/
public static function addComment(Comment $comment): ?Comment
{
try {
if (!$comment->isValid()) {
error_log(tt('moinframe.loop.comment.validation.error', ['errors' => implode(', ', $comment->validate())]));
return null;
}
$comment = self::db()::addComment($comment);
return $comment;
} catch (\Exception $e) {
error_log(tt('moinframe.loop.comment.add.failed', ['error' => $e->getMessage()]));
return null;
}
}
/**
* Adds a new reply to the database
* @param Reply $reply Reply to add
* @return Reply|null The added reply or null on failure
*/
public static function addReply(Reply $reply): ?Reply
{
try {
if (!$reply->isValid()) {
error_log(tt('moinframe.loop.reply.validation.error', ['errors' => implode(', ', $reply->validate())]));
return null;
}
$reply = self::db()::addReply($reply);
return $reply;
} catch (\Exception $e) {
error_log(tt('moinframe.loop.reply.add.failed', ['error' => $e->getMessage()]));
return null;
}
}
public static function resolveComment(string $commentId): bool
{
try {
$success = self::db()::updateCommentStatus($commentId, 'RESOLVED');
return $success;
} catch (\Exception $e) {
return false;
}
}
public static function unresolveComment(string $commentId): bool
{
try {
$success = self::db()::updateCommentStatus($commentId, 'OPEN');
return $success;
} catch (\Exception $e) {
return false;
}
}
}

View file

@ -0,0 +1,356 @@
<?php
namespace Moinframe\Loop;
use Kirby\Database\Db;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Collection;
use Moinframe\Loop\Models\Comment;
use Moinframe\Loop\Models\Reply;
class Database
{
/**
* Initializes the database connection if needed
*/
protected static function initializeDatabase(): void
{
// Get path to database file
$dbPath = Options::databasePath();
// Make sure database directory exists
$dir = dirname($dbPath);
if (is_dir($dir) === false) {
Dir::make($dir);
}
// Create empty database file if it doesn't exist
if (F::exists($dbPath) === false) {
self::createEmptyDatabase($dbPath);
}
// Configure Kirby's Db connection if not already done
if (Db::connection() === null) {
Db::connect([
'type' => 'sqlite',
'database' => $dbPath
]);
}
}
/**
* Creates an empty database with schema
* @param string $path Database file path
*/
protected static function createEmptyDatabase(string $path): void
{
$db = new \SQLite3($path);
$db->exec('PRAGMA foreign_keys = ON;');
$db->exec('CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
url TEXT NOT NULL,
page TEXT NOT NULL,
comment TEXT NOT NULL,
selector TEXT NOT NULL,
selectorOffsetX REAL NOT NULL,
selectorOffsetY REAL NOT NULL,
pagePositionX REAL NOT NULL,
pagePositionY REAL NOT NULL,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
lang TEXT NOT NULL DEFAULT ""
)');
// Create indexes for better performance
$db->exec('CREATE INDEX IF NOT EXISTS idx_comments_page_lang ON comments(page, lang)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_comments_timestamp ON comments(timestamp)');
$db->exec('CREATE TABLE IF NOT EXISTS replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
comment TEXT NOT NULL,
parentId INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
FOREIGN KEY (parentId) REFERENCES comments(id) ON DELETE CASCADE
)');
// Create indexes for replies
$db->exec('CREATE INDEX IF NOT EXISTS idx_replies_parent ON replies(parentId)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_replies_timestamp ON replies(timestamp)');
$db->close();
}
/**
* Returns a query builder for replies table
* @return \Kirby\Database\Query
*/
protected static function tableReplies(): \Kirby\Database\Query
{
self::initializeDatabase();
return Db::table('replies');
}
/**
* Returns a query builder for comments table
* @return \Kirby\Database\Query
*/
protected static function tableComments(): \Kirby\Database\Query
{
self::initializeDatabase();
return Db::table('comments');
}
/**
* Retrieves all comments from the database
* @return Collection Array of comments
*/
public static function getComments(): Collection
{
try {
$comments = self::tableComments()
->select('*')
->order('timestamp DESC')
->all();
return $comments;
} catch (\Exception $e) {
return new Collection();
}
}
/**
* Retrieves comments with their replies in a single optimized query
* @param string|null $status Filter by status (optional)
* @return Collection Array of comments with nested replies
*/
public static function getCommentsWithReplies(?string $status = null): Collection
{
try {
// Build the main comments query
$commentsQuery = self::tableComments()->select('*');
if ($status !== null) {
$commentsQuery = $commentsQuery->where('status', '!=', $status);
}
$comments = $commentsQuery->order('timestamp DESC')->all();
if ($comments->count() === 0) {
return new Collection();
}
// Get all comment IDs for efficient reply lookup
$commentIds = $comments->pluck('id');
// Single query to get all replies for these comments
$replies = self::tableReplies()
->select('*')
->where('parentId', 'in', $commentIds)
->order('timestamp ASC')
->all();
// Group replies by parentId for efficient lookup
$repliesByParent = [];
foreach ($replies as $reply) {
$parentId = $reply->parentId;
if (!isset($repliesByParent[$parentId])) {
$repliesByParent[$parentId] = [];
}
$repliesByParent[$parentId][] = $reply->toArray();
}
// Add replies to their parent comments
$result = [];
foreach ($comments as $comment) {
$commentArray = $comment->toArray();
$commentArray['replies'] = $repliesByParent[$comment->id] ?? [];
$result[] = $commentArray;
}
return new Collection($result);
} catch (\Exception $e) {
return new Collection();
}
}
/**
* Retrieves comments for a specific page with their replies
* @param string $pageUuid Page UUID to filter by
* @param string $lang Language to filter by
* @param string|null $status Status to exclude (optional)
* @return Collection Array of comments with nested replies
*/
public static function getCommentsByPage(string $pageUuid, string $lang = '', ?string $status = null): Collection
{
try {
// Build the main comments query with page filter
$commentsQuery = self::tableComments()
->select('*')
->where('page', '=', $pageUuid);
// Add language filter only if language is specified
if ($lang !== '') {
$commentsQuery = $commentsQuery->where('lang', '=', $lang);
}
if ($status !== null) {
$commentsQuery = $commentsQuery->where('status', '!=', $status);
}
$comments = $commentsQuery->order('timestamp DESC')->all();
if ($comments->count() === 0) {
return new Collection();
}
// Get all comment IDs for efficient reply lookup
$commentIds = $comments->pluck('id');
// Single query to get all replies for these comments
$replies = self::tableReplies()
->select('*')
->where('parentId', 'in', $commentIds)
->order('timestamp ASC')
->all();
// Group replies by parentId for efficient lookup
$repliesByParent = [];
foreach ($replies as $reply) {
$parentId = $reply->parentId();
if (!isset($repliesByParent[$parentId])) {
$repliesByParent[$parentId] = [];
}
$repliesByParent[$parentId][] = $reply->toArray();
}
// Add replies to their parent comments
$result = [];
foreach ($comments as $comment) {
$commentArray = $comment->toArray();
$commentArray['replies'] = $repliesByParent[$comment->id] ?? [];
$result[] = $commentArray;
}
return new Collection($result);
} catch (\Exception $e) {
return new Collection();
}
}
/**
* Retrieves all replies from the database
* @return Collection Array of comments
*/
public static function getReplies(): Collection
{
try {
$replies = self::tableReplies()
->select('*')
->order('timestamp DESC')
->all();
return $replies;
} catch (\Exception $e) {
return new Collection();
}
}
/**
* Adds a new comment to the database
* @param Comment $comment Comment data
* @return Comment|null The added comment or null on failure
*/
public static function addComment(Comment $comment): ?Comment
{
try {
$data = $comment->toArray();
// Remove id field for insertion to allow auto-increment
unset($data['id']);
$id = self::tableComments()->insert($data);
if (null !== $id) {
$comment->id = $id;
return $comment;
}
return null;
} catch (\Exception $e) {
return null;
}
}
/**
* Adds a new reply to the database
* @param Reply $reply Reply data
* @return Reply|null The added reply or null on failure
*/
public static function addReply(Reply $reply): ?Reply
{
try {
$data = $reply->toArray();
// Remove id field for insertion to allow auto-increment
unset($data['id']);
$id = self::tableReplies()->insert($data);
if (null !== $id) {
$reply->id = $id;
return $reply;
}
return null;
} catch (\Exception $e) {
return null;
}
}
// /**
// * Updates a comment in the database
// * @param string $id Comment ID
// * @param array $data Updated comment data
// * @return bool Success status
// */
// public static function updateComment(string $id, array $data): bool
// {
// try {
// $updateData = [];
// foreach ($data as $key => $value) {
// if (in_array($key, ['comment', 'selector', 'posX', 'posY'])) {
// $updateData[$key] = $value;
// }
// }
// if (empty($updateData)) {
// return false;
// }
// return self::table()->update($updateData, ['id' => $id]);
// } catch (\Exception $e) {
// return false;
// }
// }
/**
* Updates a comment's status
* @param string $id Comment ID
* @param string $status New status
* @return bool Success status
*/
public static function updateCommentStatus(string $id, string $status): bool
{
try {
return self::tableComments()->update(
['status' => $status],
['id' => $id]
);
} catch (\Exception $e) {
return false;
}
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace Moinframe\Loop\Enums;
enum CommentStatus: string
{
case OPEN = 'OPEN';
case RESOLVED = 'RESOLVED';
}

View file

@ -0,0 +1,49 @@
<?php
namespace Moinframe\Loop;
use Kirby\Http\Response;
class Middleware
{
/**
* Authentication middleware
* @param callable $next The next action to execute
* @return callable Middleware function
*/
public static function auth(callable $next): callable
{
return function () use ($next) {
// Check if loop is enabled
if (!Options::enabled()) {
return Response::json([
'status' => 'error',
'message' => 'Loop is disabled',
'code' => 'DISABLED'
], 403);
}
$csrfToken = kirby()->request()->header('X-CSRF-Token');
if (csrf($csrfToken) !== true) {
return Response::json([
'status' => 'error',
'message' => t('moinframe.loop.csrf.invalid'),
'code' => 'CSRF_INVALID'
], 403);
}
if (Options::public() === false && kirby()->user() === null) {
return Response::json([
'status' => 'error',
'message' => 'Unauthorized',
'code' => 'UNAUTHORIZED'
], 401);
}
return $next(...func_get_args());
};
}
}

View file

@ -0,0 +1,359 @@
<?php
namespace Moinframe\Loop\Models;
use Moinframe\Loop\Enums\CommentStatus;
/**
* Comment model
* @property int $id
* @property string $author
* @property string $url
* @property string $page
* @property string $comment
* @property string $selector
* @property string $lang
* @property float $selectorOffsetX
* @property float $selectorOffsetY
* @property float $pagePositionX
* @property float $pagePositionY
* @property CommentStatus $status
* @property int $timestamp
* @property Reply[] $replies
*
*/
class Comment
{
public function __construct(
public int $id = 0,
public string $author = "",
public string $url = "",
public string $page = "",
public string $comment = "",
public string $selector = "",
public float $selectorOffsetX = 0,
public float $selectorOffsetY = 0,
public float $pagePositionX = 0,
public float $pagePositionY = 0,
public CommentStatus $status = CommentStatus::OPEN,
public int $timestamp = 0,
public string $lang = "",
/** @var Reply[] */
public array $replies = []
) {}
/**
* Create a Comment instance from an array
*
* @param array{id?: int, author?: string, url?: string, page?: string, selector?: string, selectorOffsetX?: float, selectorOffsetY?: float, pagePositionX?: float, pagePositionY?: float, status?: CommentStatus, comment?: string, parentId?: int, timestamp?: int} $data
* @return self
* @throws \InvalidArgumentException If validation fails
*/
public static function fromArray($data): self
{
$errors = self::validateData($data);
if (count($errors) > 0) {
throw new \InvalidArgumentException(tt('moinframe.loop.comment.validation.failed', ['errors' => implode(', ', $errors)]));
}
$data = static::transformNumbers($data);
// Convert reply arrays to Reply objects
$replies = [];
if (isset($data['replies']) && is_array($data['replies'])) {
foreach ($data['replies'] as $replyData) {
$replies[] = Reply::fromArray($replyData);
}
}
return new self(
id: $data['id'] ?? 0,
author: strip_tags($data['author'] ?? ''),
url: $data['url'] ?? '',
page: $data['page'] ?? '',
selector: $data['selector'] ?? '',
selectorOffsetX: $data['selectorOffsetX'] ?? 0,
selectorOffsetY: $data['selectorOffsetY'] ?? 0,
pagePositionX: $data['pagePositionX'] ?? 0,
pagePositionY: $data['pagePositionY'] ?? 0,
status: isset($data['status']) ? CommentStatus::from($data['status']) : CommentStatus::OPEN,
comment: strip_tags($data['comment'] ?? ''),
timestamp: $data['timestamp'] ?? 0,
lang: $data['lang'] ?? '',
replies: $replies
);
}
/**
* Transforms numeric fields in comment data
*
* @param array<mixed> $item
* @return array<mixed>
*/
protected static function transformNumbers(array $item): array
{
// Create a new array instead of modifying the input
$result = $item;
// Handle each numeric field explicitly
if (isset($result['id'])) {
$result['id'] = (int)$result['id'];
}
if (isset($result['selectorOffsetX'])) {
$result['selectorOffsetX'] = (float)$result['selectorOffsetX'];
}
if (isset($result['selectorOffsetY'])) {
$result['selectorOffsetY'] = (float)$result['selectorOffsetY'];
}
if (isset($result['pagePositionX'])) {
$result['pagePositionX'] = (float)$result['pagePositionX'];
}
if (isset($result['pagePositionY'])) {
$result['pagePositionY'] = (float)$result['pagePositionY'];
}
if (isset($result['timestamp'])) {
$result['timestamp'] = (int)$result['timestamp'];
}
return $result;
}
/**
* Validates the comment data using Kirby validators
* @return array<string> Array of validation errors (empty if valid)
*/
/** @phpstan-ignore-next-line */
public function validate(): array
{
$rules = [
'author' => ['required', 'maxLength' => 255],
'comment' => ['required', 'maxLength' => 5000],
'page' => ['required', 'maxLength' => 255],
'selector' => ['required', 'maxLength' => 1000],
'url' => ['maxLength' => 2048],
'selectorOffsetX' => ['min' => 0],
'selectorOffsetY' => ['min' => 0],
'pagePositionX' => ['min' => 0],
'pagePositionY' => ['min' => 0],
'timestamp' => ['min' => 0]
];
$messages = [
'author' => [
t('moinframe.loop.author.required'),
t('moinframe.loop.author.max.length')
],
'comment' => [
t('moinframe.loop.comment.required'),
t('moinframe.loop.comment.max.length')
],
'page' => [
t('moinframe.loop.page.required'),
t('moinframe.loop.page.max.length')
],
'selector' => [
t('moinframe.loop.selector.required'),
t('moinframe.loop.selector.max.length')
],
'url' => t('moinframe.loop.url.max.length'),
'selectorOffsetX' => t('moinframe.loop.selector.offset.x.min'),
'selectorOffsetY' => t('moinframe.loop.selector.offset.y.min'),
'pagePositionX' => t('moinframe.loop.page.position.x.min'),
'pagePositionY' => t('moinframe.loop.page.position.y.min'),
'timestamp' => t('moinframe.loop.timestamp.min')
];
$data = $this->toArray();
// Custom URL validation if provided
if (($data['url'] ?? '') !== '' && filter_var($data['url'], FILTER_VALIDATE_URL) === false) {
return [t('moinframe.loop.url.format.invalid')];
}
/** @phpstan-ignore-next-line */
$invalid = invalid($data, $rules, $messages) ?: [];
// Convert validation errors to flat array of strings
$errors = [];
foreach ($invalid as $field => $fieldErrors) {
if (is_array($fieldErrors)) {
// Multiple validation rules failed for this field
foreach ($fieldErrors as $error) {
$errors[] = (string) $error;
}
} else {
// Single validation rule failed
$errors[] = (string) $fieldErrors;
}
}
// Validate replies
foreach ($this->replies as $index => $reply) {
$replyErrors = $reply->validate();
foreach ($replyErrors as $replyError) {
$errors[] = tt('moinframe.loop.reply.index.error', ['index' => $index, 'error' => $replyError]);
}
}
return $errors;
}
/**
* Checks if the comment is valid
* @return bool
*/
public function isValid(): bool
{
return count($this->validate()) === 0;
}
/**
* Validates data before creating Comment instance using Kirby validators
* @param array<mixed> $data
* @return array<string> Array of validation errors
*/
/** @phpstan-ignore-next-line */
public static function validateData(array $data): array
{
$rules = [
'author' => ['required', 'maxLength' => 255],
'comment' => ['required', 'maxLength' => 5000],
'page' => ['required', 'maxLength' => 255],
'selector' => ['required', 'maxLength' => 1000],
'url' => ['maxLength' => 2048],
'selectorOffsetX' => ['min' => 0],
'selectorOffsetY' => ['min' => 0],
'pagePositionX' => ['min' => 0],
'pagePositionY' => ['min' => 0],
'timestamp' => ['min' => 0]
];
$messages = [
'author' => [
t('moinframe.loop.author.required'),
t('moinframe.loop.author.max.length')
],
'comment' => [
t('moinframe.loop.comment.required'),
t('moinframe.loop.comment.max.length')
],
'page' => [
t('moinframe.loop.page.required'),
t('moinframe.loop.page.max.length')
],
'selector' => [
t('moinframe.loop.selector.required'),
t('moinframe.loop.selector.max.length')
],
'url' => t('moinframe.loop.url.max.length'),
'selectorOffsetX' => t('moinframe.loop.selector.offset.x.min'),
'selectorOffsetY' => t('moinframe.loop.selector.offset.y.min'),
'pagePositionX' => t('moinframe.loop.page.position.x.min'),
'pagePositionY' => t('moinframe.loop.page.position.y.min'),
'timestamp' => t('moinframe.loop.timestamp.min')
];
// Custom URL validation if provided
if (($data['url'] ?? '') !== '' && filter_var($data['url'], FILTER_VALIDATE_URL) === false) {
return [t('moinframe.loop.url.format.invalid')];
}
/** @phpstan-ignore-next-line */
$invalid = invalid($data, $rules, $messages) ?: [];
// Convert validation errors to flat array of strings
$errors = [];
foreach ($invalid as $field => $fieldErrors) {
if (is_array($fieldErrors)) {
// Multiple validation rules failed for this field
foreach ($fieldErrors as $error) {
$errors[] = (string) $error;
}
} else {
// Single validation rule failed
$errors[] = (string) $fieldErrors;
}
}
return $errors;
}
/**
* Resolves the author string to a display name
* If author starts with 'user://', attempts to resolve Kirby user
* Returns user's name or email prefix, otherwise returns the stored string
* @return string Resolved author display name
*/
public function resolveAuthor(): string
{
// Check if author is a Kirby user reference
if (str_starts_with($this->author, 'user://')) {
$userId = substr($this->author, 7); // Remove 'user://' prefix
try {
$user = kirby()->user($userId);
if ($user !== null && $user->exists()) {
// Return user's name if available
if ($user->name()->isNotEmpty()) {
return $user->name()->value();
}
// Fallback to email prefix (everything before @)
$email = $user->email();
if ($email !== null && str_contains($email, '@')) {
return explode('@', $email)[0];
}
// Final fallback to email
return $email !== null ? $email : $this->author;
}
} catch (\Exception) {
// If user resolution fails, fall back to original string
}
}
// Return the original author string if not a user reference or resolution failed
return $this->author;
}
/**
* Convert Comment instance to array
*
* @return array<mixed>
*/
public function toArray(): array
{
// Convert Reply objects to arrays
$replies = [];
foreach ($this->replies as $reply) {
$replies[] = $reply->toArray();
}
return [
'id' => $this->id ?? null,
'author' => $this->resolveAuthor(),
'url' => $this->url,
'page' => $this->page,
'comment' => $this->comment,
'selector' => $this->selector,
'selectorOffsetX' => $this->selectorOffsetX,
'selectorOffsetY' => $this->selectorOffsetY,
'pagePositionX' => $this->pagePositionX,
'pagePositionY' => $this->pagePositionY,
'status' => $this->status->value,
'replies' => $replies,
'timestamp' => $this->timestamp,
'lang' => $this->lang
];
}
}

View file

@ -0,0 +1,198 @@
<?php
namespace Moinframe\Loop\Models;
class Reply
{
public function __construct(
public int $id = 0,
public string $author = '',
public string $comment = '',
public int $parentId = 0,
public int $timestamp = 0
) {}
/**
* Create a Reply instance from an array
*
* @param array{id?: int, author?: string, comment?: string, parentId?: int, timestamp?: int} $data
* @return self
* @throws \InvalidArgumentException If validation fails
*/
public static function fromArray($data): self
{
$errors = self::validateData($data);
if (count($errors) > 0) {
throw new \InvalidArgumentException(tt('moinframe.loop.reply.validation.failed', ['errors' => implode(', ', $errors)]));
}
$data = static::transformNumbers($data);
return new self(
id: $data['id'] ?? 0,
author: strip_tags($data['author'] ?? ''),
comment: strip_tags($data['comment'] ?? ''),
parentId: $data['parentId'] ?? 0,
timestamp: $data['timestamp'] ?? 0
);
}
/**
* Transforms numeric fields in reply data
*
* @param array<mixed> $item
* @return array<mixed>
*/
protected static function transformNumbers(array $item): array
{
$result = $item;
if (isset($result['id'])) {
$result['id'] = (int)$result['id'];
}
if (isset($result['parentId'])) {
$result['parentId'] = (int)$result['parentId'];
}
if (isset($result['timestamp'])) {
$result['timestamp'] = (int)$result['timestamp'];
}
return $result;
}
/**
* Validates the reply data using Kirby validators
* @return array<string> Array of validation errors (empty if valid)
*/
public function validate(): array
{
$rules = [
'author' => ['required', 'maxLength' => 255],
'comment' => ['required', 'maxLength' => 5000],
'parentId' => ['required', 'min' => 1],
'timestamp' => ['min' => 0]
];
$messages = [
'author' => [
t('moinframe.loop.author.required'),
t('moinframe.loop.author.max.length')
],
'comment' => [
t('moinframe.loop.comment.required'),
t('moinframe.loop.comment.max.length')
],
'parentId' => [
t('moinframe.loop.parent.id.required'),
t('moinframe.loop.parent.id.required')
],
'timestamp' => t('moinframe.loop.timestamp.min')
];
$data = $this->toArray();
/** @phpstan-ignore-next-line */
$invalid = invalid($data, $rules, $messages) ?: [];
return array_map('strval', array_values($invalid));
}
/**
* Checks if the reply is valid
* @return bool
*/
public function isValid(): bool
{
return count($this->validate()) === 0;
}
/**
* Validates data before creating Reply instance using Kirby validators
* @param array<mixed> $data
* @return array<string> Array of validation errors
*/
public static function validateData(array $data): array
{
$rules = [
'author' => ['required', 'maxLength' => 255],
'comment' => ['required', 'maxLength' => 5000],
'parentId' => ['required', 'min' => 1],
'timestamp' => ['min' => 0]
];
$messages = [
'author' => [
t('moinframe.loop.author.required'),
t('moinframe.loop.author.max.length')
],
'comment' => [
t('moinframe.loop.comment.required'),
t('moinframe.loop.comment.max.length')
],
'parentId' => [
t('moinframe.loop.parent.id.required'),
t('moinframe.loop.parent.id.required')
],
'timestamp' => t('moinframe.loop.timestamp.min')
];
/** @phpstan-ignore-next-line */
$invalid = invalid($data, $rules, $messages) ?: [];
return array_map('strval', array_values($invalid));
}
/**
* Resolves the author string to a display name
* If author starts with 'user://', attempts to resolve Kirby user
* Returns user's name or email prefix, otherwise returns the stored string
* @return string Resolved author display name
*/
public function resolveAuthor(): string
{
// Check if author is a Kirby user reference
if (str_starts_with($this->author, 'user://')) {
$userId = substr($this->author, 7); // Remove 'user://' prefix
try {
$user = kirby()->user($userId);
if ($user !== null && $user->exists()) {
// Return user's name if available
if ($user->name()->isNotEmpty()) {
return $user->name()->value();
}
// Fallback to email prefix (everything before @)
$email = $user->email();
if ($email !== null && str_contains($email, '@')) {
return explode('@', $email)[0];
}
// Final fallback to email
return $email !== null ? $email : $this->author;
}
} catch (\Exception) {
// If user resolution fails, fall back to original string
}
}
// Return the original author string if not a user reference or resolution failed
return $this->author;
}
/**
* Convert Reply instance to array
*
* @return array<mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'author' => $this->resolveAuthor(),
'comment' => $this->comment,
'parentId' => $this->parentId,
'timestamp' => $this->timestamp
];
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace Moinframe\Loop;
class Options
{
/**
* Check if loop should be public
* @return bool
*/
public static function public(): bool
{
return option('moinframe.loop.public', false);
}
/**
* Get the path to the database file
* @return string
*/
public static function databasePath(): string
{
return option('moinframe.loop.database', kirby()->root('logs') . '/loop/comments.sqlite');
}
/**
* Get header position (top or bottom)
* @return string
*/
public static function position(): string
{
return option('moinframe.loop.position', 'top');
}
/**
* Check if auto-injection is enabled
* @return bool
*/
public static function autoInject(): bool
{
return option('moinframe.loop.auto-inject', true);
}
/**
* Check if loop is enabled for the current page
* @return bool
*/
public static function enabled(): bool
{
$enabledOption = option('moinframe.loop.enabled', true);
// If it's a boolean, return it directly
if (is_bool($enabledOption)) {
return $enabledOption;
}
// If it's a callable, execute it with the current page
if (is_callable($enabledOption)) {
$page = kirby()->site()->page();
return (bool) $enabledOption($page);
}
// Default to enabled if invalid configuration
return true;
}
/**
* Check if welcome dialog is enabled
* @return bool
*/
public static function welcomeDialogEnabled(): bool
{
return option('moinframe.loop.welcome.enabled', true);
}
/**
* Get welcome dialog headline
* @return string
*/
public static function welcomeDialogHeadline(): string
{
$customLang = self::language();
return option('moinframe.loop.welcome.headline', $customLang !== null ? t('moinframe.loop.welcome.headline', '', $customLang) : t('moinframe.loop.welcome.headline'));
}
/**
* Get welcome dialog text
* @return string
*/
public static function welcomeDialogText(): string
{
$customLang = self::language();
$translatedPosition = t('moinframe.loop.ui.header.position.' . self::position(), self::position(), $customLang);
return option('moinframe.loop.welcome.text', $customLang !== null ? tt('moinframe.loop.welcome.text', '', ['position' => $translatedPosition], $customLang) : tt('moinframe.loop.welcome.text', ['position' => $translatedPosition]));
}
/**
* Get custom language setting for loop
* @return string|null
*/
public static function language(): ?string
{
return option('moinframe.loop.language', null);
}
/**
* Set a theme
* @return string
*/
public static function theme(): string
{
return option('moinframe.loop.theme', 'default');
}
}

View file

@ -0,0 +1,312 @@
<?php
namespace Moinframe\Loop;
use Moinframe\Loop\App;
use Kirby\Http\Response;
use Moinframe\Loop\Models\Comment;
use Moinframe\Loop\Models\Reply;
class Routes
{
/**
* Standard error codes
*/
public const ERROR_CSRF_INVALID = 'CSRF_INVALID';
public const ERROR_PAGE_NOT_FOUND = 'PAGE_NOT_FOUND';
public const ERROR_FIELD_REQUIRED = 'FIELD_REQUIRED';
public const ERROR_UNAUTHORIZED = 'UNAUTHORIZED';
public const ERROR_INVALID_SELECTOR = 'INVALID_SELECTOR';
public const ERROR_INVALID_NAME = 'INVALID_NAME';
/**
* Creates a consistent error response
* @param string $message Human-readable error message
* @param string|null $code Optional error code
* @return array<string, mixed> Error response array
*/
private static function errorResponse(string $message, ?string $code = null): array
{
$response = [
'status' => 'error',
'message' => $message
];
if ($code !== null) {
$response['code'] = $code;
}
return $response;
}
/**
* Registers routes and returns route definitions
* @return array<mixed> Route definitions array
*/
public static function register(): array
{
return [
[
'pattern' => 'loop/comments/(:all)',
'method' => 'GET',
'language' => '*',
'action' => Middleware::auth(function ($language = null, $pageId = null) {
// Handle both multilingual and non-multilingual cases
if ($pageId === null && $language !== null) {
// Non-multilingual: only pageId was passed as first argument
$pageId = $language;
$language = null;
}
$onPage = null;
if ($pageId === 'home'):
$onPage = kirby()->site()->homePage();
else:
$onPage = page($pageId);
endif;
// If not found, check if it's a draft and validate access
if (null === $onPage) {
$draftPage = kirby()->page($pageId);
if ($draftPage !== null && $draftPage->isDraft() && (
(App::getKirbyMajorVersion() >= 5 && $draftPage->renderVersionFromRequest() !== null) ||
// @phpstan-ignore method.notFound
(App::getKirbyMajorVersion() < 5 && $draftPage->isVerified(get('token')) === true)
)) {
$onPage = $draftPage;
}
}
if (null === $onPage) {
return Response::json(self::errorResponse(
tt('moinframe.loop.page.not.found', ['pageId' => $pageId]),
self::ERROR_PAGE_NOT_FOUND
), 400);
}
$comments = App::getCommentsByPage($onPage, $language ?? '');
// Convert Comment objects to arrays to ensure resolved authors are included
$commentsArray = array_map(function ($comment) {
return $comment->toArray();
}, $comments);
return Response::json([
'status' => 'ok',
'comments' => $commentsArray
], 200);
})
],
[
'pattern' => 'loop/comment/new',
'method' => 'POST',
'language' => '*',
'action' => Middleware::auth(function ($language = null) {
$data = kirby()->request()->data();
// Sanitize input data
if (isset($data['comment'])) {
$data['comment'] = htmlspecialchars(strip_tags($data['comment']), ENT_QUOTES, 'UTF-8');
}
if (isset($data['selector'])) {
// Validate selector but don't HTML encode it as it needs to remain a valid CSS selector
$data['selector'] = trim($data['selector']);
// Basic validation - selector should not contain script tags or javascript
if (preg_match('/<script|javascript:|data:/i', $data['selector']) === 1) {
return Response::json(self::errorResponse(
'Invalid selector format',
self::ERROR_INVALID_SELECTOR
), 400);
}
}
$required = ['comment', 'url', 'selector', 'selectorOffsetX', 'selectorOffsetY', 'pagePositionX', 'pagePositionY', 'pageId'];
foreach ($required as $field) {
if (!isset($data[$field])) {
return Response::json(self::errorResponse(
tt('moinframe.loop.field.required', ['field' => $field]),
self::ERROR_FIELD_REQUIRED
), 400);
}
}
// Find page using page ID from data
$pageId = $data['pageId'] ?? null;
if ($pageId === null) {
return Response::json(self::errorResponse(
tt('moinframe.loop.field.required', ['field' => 'pageId']),
self::ERROR_FIELD_REQUIRED
), 400);
}
$page = ($pageId === 'home') ? kirby()->site()->homePage() : page($pageId);
// If not found, check if it's a draft and validate access
if (null === $page) {
$draftPage = kirby()->page($pageId);
if ($draftPage !== null && $draftPage->isDraft() && (
(App::getKirbyMajorVersion() >= 5 && $draftPage->renderVersionFromRequest() !== null) ||
// @phpstan-ignore method.notFound
(App::getKirbyMajorVersion() < 5 && $draftPage->isVerified(get('token')) === true)
)) {
$page = $draftPage;
}
}
if (null === $page) {
return Response::json(self::errorResponse(
tt('moinframe.loop.page.path.not.found', ['path' => $pageId]),
self::ERROR_PAGE_NOT_FOUND
), 404);
}
$comment = [
'author' => ($user = kirby()->user()) !== null ? (string) $user->uuid() : (kirby()->session()->data()->get('loop.guest.name') ?? "guest"),
'url' => $data['url'],
// @phpstan-ignore method.notFound
'page' => $page->content()->uuid(),
'comment' => $data['comment'],
'selector' => $data['selector'],
'selectorOffsetX' => $data['selectorOffsetX'],
'selectorOffsetY' => $data['selectorOffsetY'],
'pagePositionX' => $data['pagePositionX'],
'pagePositionY' => $data['pagePositionY'],
'timestamp' => time(),
'lang' => $language ?? '',
'replies' => []
];
$comment = Comment::fromArray($comment);
$result = App::addComment($comment);
return Response::json([
'status' => 'ok',
'comment' => $result !== null ? $result->toArray() : null
], 201);
})
],
[
'pattern' => 'loop/comment/resolve',
'method' => 'POST',
'language' => '*',
'action' => Middleware::auth(function ($language = null) {
$data = kirby()->request()->data();
$required = ['id'];
foreach ($required as $field) {
if (!isset($data[$field])) {
return Response::json(self::errorResponse(
tt('moinframe.loop.field.required', ['field' => $field]),
self::ERROR_FIELD_REQUIRED
), 400);
}
}
$success = App::resolveComment($data['id']);
return Response::json([
'status' => 'ok',
'success' => $success
], 200);
})
],
[
'pattern' => 'loop/comment/reply',
'method' => 'POST',
'language' => '*',
'action' => Middleware::auth(function ($language = null) {
$data = kirby()->request()->data();
// Sanitize input data
if (isset($data['comment'])) {
$data['comment'] = htmlspecialchars(strip_tags($data['comment']), ENT_QUOTES, 'UTF-8');
}
$required = ['comment', 'parentId'];
foreach ($required as $field) {
if (!isset($data[$field])) {
return Response::json(self::errorResponse(
tt('moinframe.loop.field.required', ['field' => $field]),
self::ERROR_FIELD_REQUIRED
), 400);
}
}
$reply = Reply::fromArray([
'author' => ($user = kirby()->user()) !== null ? (string) $user->uuid() : (kirby()->session()->data()->get('loop.guest.name') ?? "guest"),
'comment' => $data['comment'],
'parentId' => (int) $data['parentId'],
'timestamp' => time()
]);
$result = App::addReply($reply);
return Response::json([
'status' => 'ok',
'reply' => $result !== null ? $result->toArray() : null
], 201);
})
],
[
'pattern' => 'loop/comment/unresolve',
'method' => 'POST',
'language' => '*',
'action' => Middleware::auth(function ($language = null) {
$data = kirby()->request()->data();
$required = ['id'];
foreach ($required as $field) {
if (!isset($data[$field])) {
return Response::json(self::errorResponse(
tt('moinframe.loop.field.required', ['field' => $field]),
self::ERROR_FIELD_REQUIRED
), 400);
}
}
$success = App::unresolveComment($data['id']);
return Response::json([
'status' => 'ok',
'success' => $success
], 200);
})
],
[
'pattern' => 'loop/guest/name',
'method' => 'POST',
'language' => '*',
'action' => Middleware::auth(function ($language = null) {
$data = kirby()->request()->data();
if (!isset($data['name']) || trim($data['name']) === '') {
return Response::json(self::errorResponse(
'Name is required',
self::ERROR_INVALID_NAME
), 400);
}
$name = trim($data['name']);
kirby()->session()->data()->set('loop.guest.name', $name);
return Response::json([
'status' => 'ok',
'name' => $name
], 200);
})
]
];
}
}