add kirby-loop plugin with French translations
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
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:
parent
8ea5f0c462
commit
ab7fd8b2ea
74 changed files with 16423 additions and 2 deletions
199
site/plugins/loop/src/App.php
Normal file
199
site/plugins/loop/src/App.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
356
site/plugins/loop/src/Database.php
Normal file
356
site/plugins/loop/src/Database.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
site/plugins/loop/src/Enums/CommentStatus.php
Normal file
9
site/plugins/loop/src/Enums/CommentStatus.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop\Enums;
|
||||
|
||||
enum CommentStatus: string
|
||||
{
|
||||
case OPEN = 'OPEN';
|
||||
case RESOLVED = 'RESOLVED';
|
||||
}
|
||||
49
site/plugins/loop/src/Middleware.php
Normal file
49
site/plugins/loop/src/Middleware.php
Normal 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());
|
||||
};
|
||||
}
|
||||
}
|
||||
359
site/plugins/loop/src/Models/Comment.php
Normal file
359
site/plugins/loop/src/Models/Comment.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
198
site/plugins/loop/src/Models/Reply.php
Normal file
198
site/plugins/loop/src/Models/Reply.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
114
site/plugins/loop/src/Options.php
Normal file
114
site/plugins/loop/src/Options.php
Normal 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');
|
||||
}
|
||||
}
|
||||
312
site/plugins/loop/src/Routes.php
Normal file
312
site/plugins/loop/src/Routes.php
Normal 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);
|
||||
})
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue