OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
<?php
namespace Leantime\Domain\Wiki\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Leantime\Domain\Wiki\Models\Article;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
use Symfony\Component\HttpFoundation\Response;
class ArticleDialog extends Controller
{
private WikiService $wikiService;
private TicketService $ticketService;
public function init(WikiService $wikiService, TicketService $ticketService): void
{
$this->wikiService = $wikiService;
$this->ticketService = $ticketService;
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(WikiPermissions::VIEW)]
public function get($params): Response
{
$article = app()->make(Article::class);
$article->data = 'far fa-file-alt';
if (isset($params['id'])) {
$article = $this->wikiService->getArticle($params['id'], session('currentProject'));
}
// Delete milestone relationship
if (isset($params['removeMilestone']) === true) {
$article->milestoneId = '';
$results = $this->wikiService->updateArticle($article);
if ($results) {
$this->tpl->setNotification($this->language->__('notifications.milestone_detached'), 'success', 'articlemilestone_unlinked');
return Frontcontroller::redirect(BASE_URL.'/wiki/articleDialog/'.$article->id);
}
}
if (session('currentWiki') != '') {
$wikiHeadlines = $this->wikiService->getAllWikiHeadlines(session('currentWiki'), session('userdata.id'));
} else {
$wikiHeadlines = [];
}
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('wikiHeadlines', $wikiHeadlines);
$this->tpl->assign('article', $article);
return $this->tpl->displayPartial('wiki.articleDialog');
}
/**
* Creates or updates an article. A real dispatch-time VIEW gate guards the handler (entityScoped
* is a no-op at dispatch, which would leave this action — and its internal getArticle/
* getAllProjectWikis reads — ungated); the precise CREATE/EDIT enforcement is done in the
* service's createArticle/updateArticle against the article's real project.
*
* @throws BindingResolutionException
*/
#[RequiresPermission(WikiPermissions::VIEW)]
public function post($params): Response
{
$article = app()->make(Article::class);
if (isset($_GET['id'])) {
$id = $_GET['id'];
$article = $this->wikiService->getArticle($id, session('currentProject'));
$article->title = $params['title'];
$article->data = $params['articleIcon'];
$article->tags = $params['tags'];
$article->status = $params['status'];
$article->parent = $params['parent'];
$article->description = $params['description'];
$article->milestoneId = $params['milestoneId'] ?? $article->milestoneId;
if (isset($params['newMilestone']) && $params['newMilestone'] != '') {
$params['headline'] = $params['newMilestone'];
$params['tags'] = '#ccc';
$params['editFrom'] = dtHelper()->userNow()->formatDateForUser();
$params['editTo'] = dtHelper()->userNow()->addDays(7)->formatDateForUser();
$milestoneId = $this->ticketService->quickAddMilestone($params);
if ($milestoneId !== false) {
$article->milestoneId = $milestoneId;
}
}
if (isset($params['existingMilestone']) && $params['existingMilestone'] != '') {
$article->milestoneId = $params['existingMilestone'];
}
$results = $this->wikiService->updateArticle($article);
if ($results) {
$this->tpl->setNotification('notification.article_updated_successfully', 'success', 'article_updated');
}
} else {
// New
$article->title = $params['title'];
$article->author = session('userdata.id');
// Notes created from the "All Notes" grid have no active notebook, so
// canvasId was empty and the note saved into nothing — it appeared not
// to save. Fall back to the project's default notebook (auto-created by
// getAllProjectWikis when none exist yet). (#3216)
$canvasId = session('currentWiki');
if (empty($canvasId)) {
$projectWikis = $this->wikiService->getAllProjectWikis(session('currentProject'));
if (! empty($projectWikis)) {
$canvasId = $projectWikis[0]->id;
}
}
// If we still can't resolve a notebook, don't create an orphaned note
// with an empty canvasId (the original #3216 failure mode) — surface an
// error and send the user back to pick/create a notebook.
if (empty($canvasId)) {
$this->tpl->setNotification('notification.article_save_error_no_notebook', 'error');
return Frontcontroller::redirect(BASE_URL.'/wiki/articleDialog/');
}
$article->canvasId = $canvasId;
$article->data = $params['articleIcon'];
$article->tags = $params['tags'];
$article->status = $params['status'];
$article->parent = $params['parent'];
$article->description = $params['description'];
$id = $this->wikiService->createArticle($article);
if ($id) {
$this->tpl->setNotification('notification.article_created_successfully', 'success', 'article_created');
}
}
if (isset($params['saveAndCloseArticle']) === true && $params['saveAndCloseArticle'] == 1) {
return Frontcontroller::redirect(BASE_URL.'/wiki/articleDialog/'.$id.'?closeModal=1');
} else {
return Frontcontroller::redirect(BASE_URL.'/wiki/articleDialog/'.$id);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Leantime\Domain\Wiki\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
use Symfony\Component\HttpFoundation\Response;
class DelArticle extends Controller
{
private WikiService $wikiService;
/**
* Initializes dependencies.
*/
public function init(WikiService $wikiService): void
{
$this->wikiService = $wikiService;
}
/**
* Displays the delete article confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(WikiPermissions::DELETE)]
public function get(array $params): Response
{
return $this->tpl->displayPartial('wiki.delArticle');
}
/**
* Handles article deletion. The controller gate defers (entityScoped) to the service's
* deleteArticle(), which authorizes DELETE against the article's REAL project.
*
* @param array $params Request parameters
*/
#[RequiresPermission(WikiPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
$this->wikiService->deleteArticle($id);
$this->tpl->setNotification($this->language->__('notification.article_deleted'), 'success', 'article_deleted');
return Frontcontroller::redirect(BASE_URL.'/wiki/show');
}
return $this->tpl->displayPartial('wiki.delArticle');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Leantime\Domain\Wiki\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
use Symfony\Component\HttpFoundation\Response;
class DelWiki extends Controller
{
private WikiService $wikiService;
/**
* Initializes dependencies.
*/
public function init(WikiService $wikiService): void
{
$this->wikiService = $wikiService;
}
/**
* Displays the delete wiki confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(WikiPermissions::DELETE)]
public function get(array $params): Response
{
return $this->tpl->displayPartial('wiki.delWiki');
}
/**
* Handles wiki deletion. The controller gate defers (entityScoped) to the service's
* deleteWiki(), which authorizes DELETE against the wiki's REAL project.
*
* @param array $params Request parameters
*/
#[RequiresPermission(WikiPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
$this->wikiService->deleteWiki($id);
$this->tpl->setNotification($this->language->__('notification.wiki_deleted'), 'success', 'wiki_deleted');
return Frontcontroller::redirect(BASE_URL.'/wiki/show');
}
return $this->tpl->displayPartial('wiki.delWiki');
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace Leantime\Domain\Wiki\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
use Symfony\Component\HttpFoundation\Response;
class Show extends Controller
{
private WikiService $wikiService;
private CommentService $commentService;
private TicketService $ticketService;
public function init(WikiService $wikiService, CommentService $commentService, TicketService $ticketService): void
{
$this->wikiService = $wikiService;
$this->commentService = $commentService;
$this->ticketService = $ticketService;
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(WikiPermissions::VIEW)]
public function get(array $params): Response
{
$currentArticle = '';
$wikiHeadlines = [];
// Get all project wikis, creates one if none exists
$wikis = $this->wikiService->getAllProjectWikis(session('currentProject'));
// Special case: Setting wiki (active action), set wiki, headlines and current Article
if (isset($_GET['setWiki'])) {
$wikiId = (int) $_GET['setWiki'];
return $this->setWikiAndRedirect($wikiId);
}
if (isset($params['id'])) {
$currentArticle = $this->wikiService->setCurrentArticle($params['id'], session('usersettings.id'));
if ($currentArticle === false) {
$this->wikiService->clearWikiCache();
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
}
} elseif (
session()->exists('lastArticle') &&
session('lastArticle') != '' &&
! isset($params['id'])) {
$currentArticle = $this->wikiService->setCurrentArticle(session('lastArticle'), session('usersettings.id'));
if ($currentArticle) {
return Frontcontroller::redirect(BASE_URL.'/wiki/show/'.$currentArticle->id);
}
// If neither session is set nor the params id we are coming in fresh. Grab the article from wiki if there is one
} else {
// False is okay, just an empty wiki
$success = $this->wikiService->setCurrentWiki(session('currentWiki'));
if ($success === false) {
// Try getting the first wiki
$success = $this->wikiService->setCurrentWiki($wikis[0]->id);
if ($success === false) {
$this->wikiService->clearWikiCache();
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
}
}
$defaultArticle = $this->wikiService->getDefaultArticleForWiki(session('currentWiki'), session('userdata.id'));
if ($defaultArticle !== false) {
session(['lastArticle' => $defaultArticle->id]);
return Frontcontroller::redirect(BASE_URL.'/wiki/show/'.$defaultArticle->id);
}
// If not it's really just empty.
}
// At this point we should have a currentWiki. Even if non exist
$wikiHeadlines = $this->wikiService->getAllWikiHeadlines(session('currentWiki'), session('userdata.id'));
if (! $wikiHeadlines) {
$wikiHeadlines = [];
}
// Get the actual wiki content
$currentWiki = $this->wikiService->getWiki(session('currentWiki'));
if (empty($currentWiki)) {
$this->wikiService->clearWikiCache();
// If we can't find a current wiki at this point something went wrong
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
}
// Delete comment
if (isset($_GET['delComment']) === true) {
$commentId = (int) ($_GET['delComment']);
$this->commentService->deleteComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success', 'wikicomment_deleted');
}
if (isset($currentArticle->id)) {
$comment = $this->commentService->getComments('article', $currentArticle->id, 0);
} else {
$comment = [];
}
// Get all milestones for the project
$allProjectMilestones = $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => session('currentProject'),
]);
$this->tpl->assign('comments', $comment);
$this->tpl->assign('numComments', count($comment));
$this->tpl->assign('currentArticle', $currentArticle);
$this->tpl->assign('currentWiki', $currentWiki);
$this->tpl->assign('wikis', $wikis);
$this->tpl->assign('wikiHeadlines', $wikiHeadlines);
$this->tpl->assign('milestones', $allProjectMilestones);
return $this->tpl->display('wiki.show');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(WikiPermissions::VIEW)]
public function post(array $params): Response
{
if (isset($_GET['id']) === true) {
$id = (int) ($_GET['id']);
$currentArticle = $this->wikiService->getArticle($id, session('currentProject'));
if (isset($_POST['comment']) === true) {
if ($this->commentService->addComment($_POST, 'article', $id, $currentArticle)) {
$this->tpl->setNotification(
$this->language->__('notifications.comment_create_success'),
'success',
'wikicomment_created'
);
} else {
$this->tpl->setNotification($this->language->__('notifications.comment_create_error'), 'error');
}
}
return Frontcontroller::redirect(BASE_URL.'/wiki/show/'.$id);
}
return Frontcontroller::redirect(BASE_URL.'/wiki/show/');
}
protected function setWikiAndRedirect($id): Response
{
$this->wikiService->clearWikiCache();
$success = $this->wikiService->setCurrentWiki($id);
if ($success === false) {
$this->wikiService->clearWikiCache();
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
}
$defaultArticle = $this->wikiService->getDefaultArticleForWiki($id, session('userdata.id'));
if ($defaultArticle !== false) {
session(['lastArticle' => $defaultArticle->id]);
return Frontcontroller::redirect(BASE_URL.'/wiki/show/'.$defaultArticle->id);
}
return Frontcontroller::redirect(BASE_URL.'/wiki/show/');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Leantime\Domain\Wiki\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Symfony\Component\HttpFoundation\Response;
class Templates extends Controller
{
public function init(): void {}
/**
* @throws \Exception
*/
#[RequiresPermission(WikiPermissions::VIEW)]
public function get($params): Response
{
return $this->tpl->displayPartial('wiki.templates');
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Leantime\Domain\Wiki\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Wiki\Models\Wiki;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
use Symfony\Component\HttpFoundation\Response;
class WikiModal extends Controller
{
private WikiService $wikiService;
public function init(WikiService $wikiService): void
{
$this->wikiService = $wikiService;
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(WikiPermissions::VIEW)]
public function get($params): Response
{
$wiki = app()->make(Wiki::class);
if (isset($_GET['id'])) {
$wiki = $this->wikiService->getWiki($_GET['id']);
}
$this->tpl->assign('wiki', $wiki);
return $this->tpl->displayPartial('wiki.wikiDialog');
}
/**
* Creates or updates a wiki (notebook). The controller gate defers (entityScoped) to the
* service's createWiki/updateWiki, which authorize CREATE/EDIT against the wiki's real project.
*
* @throws BindingResolutionException
*/
#[RequiresPermission(WikiPermissions::EDIT, entityScoped: true)]
public function post($params): Response
{
$wiki = app()->make(Wiki::class);
if (isset($_GET['id'])) {
$id = (int) $_GET['id'];
// Update
$wiki->title = $params['title'];
$this->wikiService->updateWiki($wiki, $id);
$this->tpl->setNotification('notification.wiki_updated_successfully', 'success', 'wiki_updated');
return Frontcontroller::redirect(BASE_URL.'/wiki/wikiModal/'.$id);
} else {
// New
$wiki->title = $params['title'];
$wiki->projectId = session('currentProject');
$wiki->author = session('userdata.id');
$id = $this->wikiService->createWiki($wiki);
// session(["currentWiki" => $id]);
if ($id) {
$this->tpl->setNotification('notification.wiki_created_successfully', 'success', 'wiki_created');
return Frontcontroller::redirect(BASE_URL.'/wiki/wikiModal/'.$id.'?closeModal=1');
}
return Frontcontroller::redirect(BASE_URL.'/wiki/wikiModal/'.$id.'');
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Wiki\Hxcontrollers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki;
/**
* HTMX Controller for wiki article activity feed
*/
class ArticleActivity extends HtmxController
{
protected static string $view = 'wiki::partials.activityFeed';
private Wiki $wikiService;
public function init(Wiki $wikiService): void
{
$this->wikiService = $wikiService;
}
/**
* Get the activity feed for an article. The service's getArticleActivity() is the precise
* per-article-project IDOR fence; this VIEW gate stops non-viewers at dispatch.
*/
#[RequiresPermission(WikiPermissions::VIEW, entityScoped: true)]
public function get(): void
{
$articleId = (int) $this->incomingRequest->query->get('articleId', 0);
if ($articleId <= 0) {
$this->tpl->assign('activity', []);
$this->tpl->assign('articleId', 0);
return;
}
// Get the article for created/modified fallback
$article = $this->wikiService->getArticle($articleId);
$activity = $this->wikiService->getArticleActivity($articleId, 20);
// Always append the article's created date as the final entry
if ($article && ! empty($article->created)) {
$activity[] = [
'type' => 'baseline',
'action' => 'article.create',
'date' => $article->created,
'firstname' => $article->firstname ?? '',
'lastname' => $article->lastname ?? '',
'profileId' => $article->profileId ?? '',
'values' => [],
];
}
$this->tpl->assign('activity', $activity);
$this->tpl->assign('articleId', $articleId);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Wiki\Hxcontrollers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Wiki\Models\Article;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki;
use Symfony\Component\HttpFoundation\Response;
/**
* HTMX Controller for inline wiki article content editing
*/
class ArticleContent extends HtmxController
{
protected static string $view = 'wiki::partials.articleContent';
private Wiki $wikiService;
public function init(Wiki $wikiService): void
{
$this->wikiService = $wikiService;
}
/**
* Save article content via HTMX (called on auto-save or blur).
*
* Gate defers (entityScoped) to the service's updateArticle(), which authorizes EDIT against
* the article's real project.
*/
#[RequiresPermission(WikiPermissions::EDIT, entityScoped: true)]
public function save(): Response
{
$articleId = (int) $this->incomingRequest->query->get('articleId', 0);
$content = $this->incomingRequest->request->get('description');
$title = $this->incomingRequest->request->get('title');
$status = $this->incomingRequest->request->get('status');
$icon = $this->incomingRequest->request->get('icon');
$tags = $this->incomingRequest->request->get('tags');
$milestoneId = $this->incomingRequest->request->get('milestoneId');
$parent = $this->incomingRequest->request->get('parent');
if (! $articleId) {
return new Response('Article ID required', 400);
}
// Get the existing article
$existingArticle = $this->wikiService->getArticle($articleId);
if (! $existingArticle) {
return new Response('Article not found', 404);
}
// Create article model with updated fields
$article = new Article;
$article->id = $articleId;
$article->title = $title !== null ? $title : $existingArticle->title;
$article->description = $content !== null ? $content : $existingArticle->description;
$article->canvasId = $existingArticle->canvasId;
$article->tags = $tags !== null ? $tags : $existingArticle->tags;
$article->data = $icon !== null ? $icon : $existingArticle->data;
$article->status = $status !== null ? $status : $existingArticle->status;
$article->milestoneId = $milestoneId !== null ? (int) ($milestoneId !== '' ? $milestoneId : 0) : $existingArticle->milestoneId;
$article->parent = $parent !== null ? $parent : $existingArticle->parent;
$article->sortindex = $existingArticle->sortindex;
if ($this->wikiService->updateArticle($article, $existingArticle)) {
return new Response(json_encode([
'success' => true,
'message' => 'Saved',
'timestamp' => dtHelper()->userNow()->formatDateTimeForDb(),
'title' => $article->title,
'status' => $article->status,
], JSON_THROW_ON_ERROR), 200, ['Content-Type' => 'application/json']);
}
return new Response(json_encode([
'success' => false,
'message' => 'Failed to save',
], JSON_THROW_ON_ERROR), 500, ['Content-Type' => 'application/json']);
}
/**
* Create a new article and redirect to it.
*
* Gate defers (entityScoped) to the service's createArticle(), which authorizes CREATE against
* the target wiki's project.
*/
#[RequiresPermission(WikiPermissions::CREATE, entityScoped: true)]
public function create(): Response
{
$currentWiki = session('currentWiki');
if (! $currentWiki) {
return new Response('No wiki selected', 400);
}
// Create new article with defaults
$article = new Article;
$article->title = 'Untitled';
$article->author = session('userdata.id');
$article->canvasId = $currentWiki;
$article->data = 'far fa-file-alt';
$article->tags = '';
$article->status = 'draft';
$article->parent = 0;
$article->description = '';
$id = $this->wikiService->createArticle($article);
if ($id) {
$response = new Response('', 200);
$response->headers->set('HX-Redirect', BASE_URL.'/wiki/show/'.$id);
return $response;
}
return new Response('Failed to create article', 500);
}
}

View File

@@ -0,0 +1,52 @@
leantime.wikiController = (function () {
//Functions
var initTree = function (id, selectedId) {
jQuery(id).jstree({
"core": {
"expand_selected_onload":true,
"themes": {
"dots":false
}
},
"state" : {
"key" : "tree_state",
},
"types" : {
"default": {
"icon": "far fa-file-alt"
},
},
"plugins" : ["wholerow", "types", "state"]
});
jQuery(id).on("ready.jstree", function (e, data) {
jQuery(this).jstree("deselect_all");
jQuery(this).jstree("select_node", "treenode_" + selectedId + "", true);
jQuery(this).jstree("save_state");
})
jQuery(id).on('activate_node.jstree', function (e, data) {
jQuery(this).jstree("save_state");
if (data == undefined || data.node == undefined || data.node.id == undefined) {
return;
}
window.location.href = data.node.a_attr.href;
});
}
// Make public what you want to have public, everything else is private
return {
initTree: initTree,
};
})();

View File

@@ -0,0 +1,58 @@
<?php
namespace Leantime\Domain\Wiki\Models;
class Article
{
public $id;
public $title;
public $description;
public $canvasId;
public $parent;
public $tags;
public $data;
public $status;
public $created;
public $modified;
public $author;
public $milestoneId;
public $firstname;
public $lastname;
public $profileId;
public $sortindex;
public $projectId;
public $milestoneHeadline;
public $milestoneEditTo;
public $doneTickets;
public $openTicketsEffort;
public $doneTicketsEffort;
public $allTicketsEffort;
public $allTickets;
public $percentDone;
public function __construct() {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Leantime\Domain\Wiki\Models;
class Template
{
public $title;
public $description;
public $content;
public $category;
public function __construct() {}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Leantime\Domain\Wiki\Models;
class Wiki
{
public $id;
public $title;
public $author;
public $created;
public $projectId;
public $category;
public function __construct() {}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Leantime\Domain\Wiki\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Wiki permission vocabulary — the verbs only.
*
* Wiki articles and wikis are PROJECT-scoped (each belongs to one project), so every capability is
* evaluated against the user's role IN that project (projectScoped = true, the default). The
* standard verbs auto-grant via the central matrix (readonly = view; editor = create/edit/delete;
* manager+ = all), so no {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions} change is
* required.
*/
final class WikiPermissions implements ProvidesPermissions
{
public const VIEW = 'wiki.view';
public const CREATE = 'wiki.create';
public const EDIT = 'wiki.edit';
public const DELETE = 'wiki.delete';
public function domain(): string
{
return 'wiki';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View wiki articles'),
new Permission(self::CREATE, 'Create wiki articles'),
new Permission(self::EDIT, 'Edit wiki articles'),
new Permission(self::DELETE, 'Delete wiki articles'),
];
}
}

View File

@@ -0,0 +1,339 @@
<?php
namespace Leantime\Domain\Wiki\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\Blueprints\Repositories\Blueprints;
use Leantime\Domain\Tickets\Repositories\Tickets;
use Leantime\Domain\Wiki\Models\Article;
use Leantime\Domain\Wiki\Models\Wiki as WikiModel;
class Wiki extends Blueprints
{
/**
* Canvas type slug used to derive the Blueprints canvas type ("wikicanvas")
* and comment module ("wikicanvasitem").
*/
protected const CANVAS_NAME = 'wiki';
protected ConnectionInterface $dbConnection;
public function __construct(DbCore $db, Tickets $ticketRepo, DatabaseHelper $dbHelper)
{
parent::__construct($db, $ticketRepo, $dbHelper);
$this->dbConnection = $db->getConnection();
}
/**
* Get all canvas boards for a project, defaulting to the wiki canvas type.
*
* @param int $projectId Project ID
* @param string|null $type Canvas type override (defaults to "wikicanvas")
* @return false|array<int, array<string, mixed>>
*/
public function getAllCanvas($projectId, $type = null): false|array
{
return parent::getAllCanvas((int) $projectId, $type ?: 'wikicanvas');
}
/**
* Create a canvas board, defaulting to the wiki canvas type.
*
* @param array<string, mixed> $values Canvas values
* @param string|null $type Canvas type override (defaults to "wikicanvas")
*/
public function addCanvas($values, $type = null): false|string
{
return parent::addCanvas($values, $type ?: 'wikicanvas');
}
/**
* Get the items for a canvas board, using the wiki comment module.
*
* @param int $id Canvas board ID
* @param string $commentModule Comment module override (defaults to "wikicanvasitem")
* @return false|array<int, array<string, mixed>>
*/
public function getCanvasItemsById($id, $commentModule = 'wikicanvasitem'): false|array
{
return parent::getCanvasItemsById((int) $id, $commentModule ?: 'wikicanvasitem');
}
public function getArticle(int $id, int $projectId): mixed
{
$query = $this->dbConnection->table('zp_canvas_items')
->select(
'zp_canvas_items.id',
'zp_canvas_items.title',
'zp_canvas_items.description',
'zp_canvas_items.canvasId',
'zp_canvas_items.parent',
'zp_canvas_items.tags',
'zp_canvas_items.data',
'zp_canvas_items.status',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.author',
'zp_canvas_items.milestoneId',
'zp_user.firstname',
'zp_user.lastname',
'zp_user.profileId',
'zp_canvas_items.sortindex',
'zp_canvas.projectId',
'milestone.headline as milestoneHeadline',
'milestone.editTo as milestoneEditTo'
)
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->leftJoin('zp_user', 'zp_canvas_items.author', '=', 'zp_user.id')
->leftJoin('zp_tickets AS milestone', function ($join) {
$join->on('zp_canvas_items.milestoneId', '=',
$this->dbConnection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
})
->where('zp_canvas.projectId', $projectId)
->where('zp_canvas_items.box', 'article');
if ($id > 0) {
$query->where('zp_canvas_items.id', $id);
} elseif ($id == -1) {
$query->where('featured', 1);
}
$result = $query->limit(1)->first();
if ($result === null) {
return false;
}
$article = new Article;
foreach ($result as $key => $value) {
if (property_exists($article, $key)) {
$article->$key = $value;
}
}
return $article;
}
/**
* Resolve an article's owning project by its id alone (articles inherit their wiki's project
* via canvasId -> zp_canvas.projectId). Used by the service to authorize edit/delete/activity
* against the article's REAL project without trusting a caller-supplied projectId.
*/
public function getArticleProjectId(int $id): ?int
{
$projectId = $this->dbConnection->table('zp_canvas_items')
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->where('zp_canvas_items.id', $id)
->where('zp_canvas_items.box', 'article')
->value('zp_canvas.projectId');
return $projectId !== null ? (int) $projectId : null;
}
public function getAllProjectWikis(int $projectId): array|false
{
$results = $this->dbConnection->table('zp_canvas')
->select('id', 'title', 'author', 'created')
->where('projectId', $projectId)
->where('type', 'wiki')
->get();
return $results->map(function ($row) {
$wiki = new WikiModel;
$wiki->id = $row->id;
$wiki->title = $row->title;
$wiki->author = $row->author;
$wiki->created = $row->created;
return $wiki;
})->toArray();
}
public function getWiki(int $id): mixed
{
$result = $this->dbConnection->table('zp_canvas')
->select('id', 'title', 'author', 'created', 'projectId')
->where('id', $id)
->where('type', 'wiki')
->first();
if ($result === null) {
return false;
}
$wiki = new WikiModel;
$wiki->id = $result->id;
$wiki->title = $result->title;
$wiki->author = $result->author;
$wiki->created = $result->created;
$wiki->projectId = $result->projectId;
return $wiki;
}
public function getAllWikiHeadlines(int $canvasId, int $userId): false|array
{
$results = $this->dbConnection->table('zp_canvas_items')
->select('id', 'title', 'parent', 'sortindex', 'status', 'data')
->where('canvasId', $canvasId)
->where('box', 'article')
->where(function ($query) use ($userId) {
$query->where('status', 'published')
->orWhere(function ($q) use ($userId) {
$q->where('status', 'draft')
->where('author', $userId);
});
})
->orderBy('parent')
->orderBy('title')
->get();
return $results->map(function ($row) {
$article = new Article;
$article->id = $row->id;
$article->title = $row->title;
$article->parent = $row->parent;
$article->sortindex = $row->sortindex;
$article->status = $row->status;
$article->data = $row->data;
return $article;
})->toArray();
}
public function createWiki(WikiModel $wiki): false|string
{
$id = $this->dbConnection->table('zp_canvas')->insertGetId([
'title' => $wiki->title,
'projectId' => $wiki->projectId,
'author' => $wiki->author,
'created' => date('Y-m-d'),
'type' => 'wiki',
]);
return (string) $id;
}
public function updateWiki(WikiModel $wiki, int $wikiId): bool
{
// type guard: zp_canvas is shared across all canvas types (one id sequence), so scope the
// write to wiki rows — a non-wiki id can never rename another project's canvas board.
return $this->dbConnection->table('zp_canvas')
->where('id', $wikiId)
->where('type', 'wiki')
->update(['title' => $wiki->title]) >= 0;
}
public function createArticle(Article $article): false|string
{
$id = $this->dbConnection->table('zp_canvas_items')->insertGetId([
'title' => $article->title,
'description' => $article->description,
'data' => $article->data,
'box' => 'article',
'author' => $article->author,
'canvasId' => $article->canvasId,
'parent' => $article->parent,
'tags' => $article->tags,
'status' => $article->status,
'created' => date('Y-m-d'),
'modified' => date('Y-m-d'),
'sortindex' => '10',
]);
return (string) $id;
}
public function updateArticle(Article $article): bool
{
// box guard: zp_canvas_items is shared across all canvas types (one id sequence), so scope
// the write to article rows — a non-article id can never touch a goal/SWOT/risk item.
return $this->dbConnection->table('zp_canvas_items')
->where('id', $article->id)
->where('box', 'article')
->update([
'title' => $article->title,
'description' => $article->description,
'data' => $article->data,
'parent' => $article->parent,
'tags' => $article->tags,
'status' => $article->status,
'modified' => date('Y-m-d'),
'milestoneId' => $article->milestoneId,
]) >= 0;
}
public function delArticle(int $id): void
{
// box guard (shared zp_canvas_items): only ever delete an article row by this id.
$this->dbConnection->table('zp_canvas_items')
->where('id', $id)
->where('box', 'article')
->delete();
}
public function delWiki(int $id): void
{
// zp_canvas/zp_canvas_items are shared across all canvas families. Early-return unless this
// id is an actual wiki board, so the items-delete (by canvasId) can never drop another
// canvas type's items before the type-guarded board delete runs.
$isWiki = $this->dbConnection->table('zp_canvas')
->where('id', $id)
->where('type', 'wiki')
->exists();
if (! $isWiki) {
return;
}
$this->dbConnection->table('zp_canvas_items')
->where('canvasId', $id)
->delete();
$this->dbConnection->table('zp_canvas')
->where('id', $id)
->where('type', 'wiki')
->delete();
}
/**
* Count wiki boards, optionally scoped to a project.
*
* @param int|null $projectId Project ID (null counts across all projects)
* @param string $canvasType Canvas type override (defaults to the "wiki" board type)
* @return int|mixed
*/
public function getNumberOfBoards($projectId = null, $canvasType = 'wiki'): mixed
{
$query = $this->dbConnection->table('zp_canvas')
->where('type', $canvasType ?: 'wiki');
if ($projectId !== null) {
$query->where('projectId', $projectId);
}
return $query->count();
}
/**
* Count wiki canvas items, optionally scoped to a project.
*
* @param int|null $projectId Project ID (null counts across all projects)
* @param string $canvasType Canvas type override (defaults to the "wiki" board type)
* @return int|mixed
*/
public function getNumberOfCanvasItems($projectId = null, $canvasType = 'wiki'): mixed
{
$query = $this->dbConnection->table('zp_canvas_items')
->leftJoin('zp_canvas AS canvasBoard', 'zp_canvas_items.canvasId', '=', 'canvasBoard.id')
->where('canvasBoard.type', $canvasType ?: 'wiki');
if ($projectId !== null) {
$query->where('canvasBoard.projectId', $projectId);
}
return $query->count('zp_canvas_items.id');
}
}

View File

@@ -0,0 +1,493 @@
<?php
namespace Leantime\Domain\Wiki\Services;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Language;
use Leantime\Domain\Audit\Repositories\Audit as AuditRepository;
use Leantime\Domain\Wiki\Models\Article;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Repositories\Wiki as WikiRepository;
/**
* @api
*/
class Wiki extends BaseService
{
private WikiRepository $wikiRepository;
private Language $language;
private AuditRepository $auditRepo;
public function __construct(
WikiRepository $wikiRepository,
Language $language,
AuditRepository $auditRepo
) {
$this->wikiRepository = $wikiRepository;
$this->language = $language;
$this->auditRepo = $auditRepo;
}
/**
* Get an article by ID and project.
*
* The repository already filters by project, so this read is project-scoped: when a projectId
* is passed it is authorized against (closing the explicit-foreign-project RPC read); when it is
* omitted the call resolves to the caller's session project and can only read articles there.
*
* @api
*/
#[RequiresPermission(WikiPermissions::VIEW, projectIdParam: 'projectId')]
public function getArticle(?int $id, ?int $projectId = null): mixed
{
if ($projectId === null) {
$projectId = session('currentProject');
}
if (! is_null($id)) {
$article = $this->wikiRepository->getArticle($id, $projectId);
if (! $article) {
$article = $this->wikiRepository->getArticle(-1, $projectId);
}
} else {
$article = $this->wikiRepository->getArticle(-1, $projectId);
}
return $article;
}
/**
* Gets all project wikis. Creates one if there aren't any
*
*
* @api
*/
#[RequiresPermission(WikiPermissions::VIEW, projectIdParam: 'projectId')]
public function getAllProjectWikis($projectId): array|false
{
$wikis = $this->wikiRepository->getAllProjectWikis($projectId);
if (! $wikis) {
$wiki = app()->make(\Leantime\Domain\Wiki\Models\Wiki::class);
$wiki->title = $this->language->__('label.default');
$wiki->projectId = $projectId;
$wiki->author = session('userdata.id');
// Bootstrap only: the default notebook is created as a SIDE EFFECT of viewing a
// wiki-less project, so it must NOT go through the create-authorized createWiki() —
// that would 403 a readonly viewer. Write straight to the repository (system action).
$this->wikiRepository->createWiki($wiki);
$wikis = $this->wikiRepository->getAllProjectWikis($projectId);
}
return $wikis;
}
/**
* List the article headlines in a wiki.
*
* @api
*/
#[RequiresPermission(WikiPermissions::VIEW, entityScoped: true)]
public function getAllWikiHeadlines($wikiId, $userId): false|array
{
// IDOR fence: a foreign wikiId would otherwise leak another project's article titles.
// Resolve the wiki's project and authorize VIEW there before listing.
$wiki = $this->wikiRepository->getWiki((int) $wikiId);
if (! $wiki) {
return false;
}
$this->authorize(WikiPermissions::VIEW, (int) $wiki->projectId);
return $this->wikiRepository->getAllWikiHeadlines($wikiId, $userId);
}
/**
* Get a single wiki (notebook) by id.
*
* @api
*/
#[RequiresPermission(WikiPermissions::VIEW, entityScoped: true)]
public function getWiki($id): mixed
{
if ($id === null) {
return false;
}
$wiki = $this->wikiRepository->getWiki((int) $id);
if (! $wiki) {
return false;
}
// IDOR fence: the id alone names any project's wiki. Authorize VIEW against the wiki's
// ACTUAL project, not the session project — closes the cross-project read (and the
// ?setWiki= session-switch footgun, which routes through here) on every call surface.
$this->authorize(WikiPermissions::VIEW, (int) $wiki->projectId);
return $wiki;
}
/**
* @api
*/
#[RequiresPermission(WikiPermissions::CREATE, entityScoped: true)]
public function createWiki(\Leantime\Domain\Wiki\Models\Wiki $wiki): false|string
{
// Authorize CREATE against the target project before writing. An RPC caller could set any
// projectId on the model, so the check is against that value (denied for non-members).
$projectId = (int) ($wiki->projectId ?? session('currentProject'));
$this->authorize(WikiPermissions::CREATE, $projectId);
$wikiId = $this->wikiRepository->createWiki($wiki);
$this->setCurrentWiki($wikiId);
return $wikiId;
}
/**
* @api
*/
#[RequiresPermission(WikiPermissions::EDIT, entityScoped: true)]
public function updateWiki(\Leantime\Domain\Wiki\Models\Wiki $wiki, $wikiId): bool
{
// IDOR fence: authorize EDIT against the EXISTING wiki's real project (the incoming model's
// projectId is untrusted) before writing. FAIL CLOSED when the id is not a wiki — zp_canvas
// is shared across canvas types (one id sequence), and getWiki filters type='wiki', so a
// non-wiki id resolves to false; refuse rather than fall through to an unguarded title write
// that would rename another project's canvas board.
$existing = $this->wikiRepository->getWiki((int) $wikiId);
if (! $existing) {
return false;
}
$this->authorize(WikiPermissions::EDIT, (int) $existing->projectId);
return $this->wikiRepository->updateWiki($wiki, $wikiId);
}
/**
* Create a new article and record an audit event.
*
* @api
*/
#[RequiresPermission(WikiPermissions::CREATE, entityScoped: true)]
public function createArticle(Article $article): false|string
{
// An article inherits its wiki's project (canvasId -> zp_canvas.projectId). FAIL CLOSED if
// the canvasId is not a wiki — never fall back to the session project, or a foreign/non-wiki
// canvasId could be created against the caller's own project.
$wiki = $this->wikiRepository->getWiki((int) $article->canvasId);
if (! $wiki) {
return false;
}
$projectId = (int) $wiki->projectId;
$this->authorize(WikiPermissions::CREATE, $projectId);
$id = $this->wikiRepository->createArticle($article);
if ($id !== false) {
$this->auditRepo->storeEvent(
action: 'article.create',
values: json_encode(['title' => $article->title], JSON_THROW_ON_ERROR),
entity: 'article',
entityId: (int) $id,
userId: (int) session('userdata.id'),
projectId: $projectId
);
}
return $id;
}
/**
* Update an article and record audit events for changed fields.
*
* @api
*/
#[RequiresPermission(WikiPermissions::EDIT, entityScoped: true)]
public function updateArticle(Article $article, ?Article $existingArticle = null): bool
{
// IDOR fence: resolve the EXISTING article's real project (by id) and authorize EDIT there
// before writing. The incoming model's project/canvas are untrusted, so an editor in
// project A cannot edit or relocate an article that lives in project B. FAIL CLOSED on an
// unresolved project: zp_canvas_items is a shared table (one id sequence across ALL canvas
// types), so a null here means the id is not an article — refuse rather than write a row we
// could not authorize (a non-article id would otherwise overwrite a goal/SWOT/risk item).
$projectId = $this->wikiRepository->getArticleProjectId((int) $article->id);
if ($projectId === null) {
return false;
}
$this->authorize(WikiPermissions::EDIT, $projectId);
$result = $this->wikiRepository->updateArticle($article);
if ($result && $existingArticle !== null) {
$this->recordArticleChanges($existingArticle, $article);
}
return $result;
}
/**
* Delete an article, fencing the operation against the article's project.
*
* @api
*/
#[RequiresPermission(WikiPermissions::DELETE, entityScoped: true)]
public function deleteArticle(int $id): bool
{
// IDOR fence: the id alone identified the row before (the controller called the repository
// directly), so any editor could delete another project's article. Authorize DELETE against
// the article's real project first.
$projectId = $this->wikiRepository->getArticleProjectId($id);
if ($projectId === null) {
return false;
}
$this->authorize(WikiPermissions::DELETE, $projectId);
$this->wikiRepository->delArticle($id);
$this->auditRepo->storeEvent(
action: 'article.delete',
values: '',
entity: 'article',
entityId: $id,
userId: (int) session('userdata.id'),
projectId: $projectId
);
session()->forget('lastArticle');
return true;
}
/**
* Delete a wiki (notebook), fencing the operation against the wiki's project.
*
* @api
*/
#[RequiresPermission(WikiPermissions::DELETE, entityScoped: true)]
public function deleteWiki(int $id): bool
{
// IDOR fence: authorize DELETE against the wiki's real project before removing it.
$wiki = $this->wikiRepository->getWiki($id);
if (! $wiki) {
return false;
}
$this->authorize(WikiPermissions::DELETE, (int) $wiki->projectId);
$this->wikiRepository->delWiki($id);
session()->forget('currentWiki');
session()->forget('lastArticle');
return true;
}
public function setCurrentWiki($id)
{
// Clear cache
$this->clearWikiCache();
$wiki = $this->getWiki($id);
if ($wiki) {
// Set the session
session(['currentWiki' => $id]);
return true;
}
return false;
}
public function setCurrentArticle($id, $userId)
{
$currentArticle = $this->getArticle($id);
if ($currentArticle && $currentArticle->id != null) {
session(['currentWiki' => $currentArticle->canvasId]);
session(['lastArticle' => $currentArticle->id]);
return $currentArticle;
}
return false;
}
public function getDefaultArticleForWiki($wikiId, $userId)
{
$wikiHeadlines = $this->getAllWikiHeadlines(
$wikiId,
$userId
);
if (is_array($wikiHeadlines) && count($wikiHeadlines) > 0) {
$currentArticle = $this->getArticle(
$wikiHeadlines[0]->id
);
return $currentArticle;
}
return false;
}
/**
* Get combined activity feed for an article (audit events + comments).
*
* @api
*
* @return array<int, array<string, mixed>>
*/
#[RequiresPermission(WikiPermissions::VIEW, entityScoped: true)]
public function getArticleActivity(int $articleId, int $limit = 20): array
{
// IDOR fence: a foreign articleId would otherwise leak another project's edit history
// (audit values include old/new titles). Authorize VIEW against the article's real project.
$projectId = $this->wikiRepository->getArticleProjectId($articleId);
if ($projectId === null) {
return [];
}
$this->authorize(WikiPermissions::VIEW, $projectId);
$activity = [];
// Get audit events for this article
$auditEvents = $this->auditRepo->getEventsForEntity('article', $articleId, $limit);
foreach ($auditEvents as $event) {
$decoded = ! empty($event['values']) ? json_decode($event['values'], true) : [];
$values = is_array($decoded) ? $decoded : [];
$activity[] = [
'type' => 'audit',
'action' => $event['action'] ?? '',
'date' => $event['date'] ?? '',
'firstname' => $event['firstname'] ?? '',
'lastname' => $event['lastname'] ?? '',
'profileId' => $event['profileId'] ?? '',
'values' => $values,
];
}
return array_slice($activity, 0, $limit);
}
/**
* Record audit events for changed fields between existing and updated articles.
*/
private function recordArticleChanges(Article $existing, Article $updated): void
{
$userId = (int) session('userdata.id');
$projectId = (int) session('currentProject');
$articleId = (int) $updated->id;
if ($updated->title !== $existing->title) {
$this->auditRepo->storeEvent(
action: 'article.title',
values: json_encode(['from' => $existing->title, 'to' => $updated->title], JSON_THROW_ON_ERROR),
entity: 'article',
entityId: $articleId,
userId: $userId,
projectId: $projectId
);
}
if ($updated->status !== $existing->status) {
$this->auditRepo->storeEvent(
action: 'article.status',
values: json_encode(['from' => $existing->status, 'to' => $updated->status], JSON_THROW_ON_ERROR),
entity: 'article',
entityId: $articleId,
userId: $userId,
projectId: $projectId
);
}
if ((int) $updated->parent !== (int) $existing->parent) {
$this->auditRepo->storeEvent(
action: 'article.parent',
values: json_encode(['from' => $existing->parent, 'to' => $updated->parent], JSON_THROW_ON_ERROR),
entity: 'article',
entityId: $articleId,
userId: $userId,
projectId: $projectId
);
}
if ((int) $updated->milestoneId !== (int) $existing->milestoneId) {
$this->auditRepo->storeEvent(
action: 'article.milestone',
values: json_encode(['from' => $existing->milestoneId, 'to' => $updated->milestoneId], JSON_THROW_ON_ERROR),
entity: 'article',
entityId: $articleId,
userId: $userId,
projectId: $projectId
);
}
if ($updated->data !== ($existing->data ?? '')) {
$this->auditRepo->storeEvent(
action: 'article.icon',
values: json_encode(['from' => $existing->data, 'to' => $updated->data], JSON_THROW_ON_ERROR),
entity: 'article',
entityId: $articleId,
userId: $userId,
projectId: $projectId
);
}
if ($updated->tags !== ($existing->tags ?? '')) {
$this->auditRepo->storeEvent(
action: 'article.tags',
values: json_encode(['from' => $existing->tags, 'to' => $updated->tags], JSON_THROW_ON_ERROR),
entity: 'article',
entityId: $articleId,
userId: $userId,
projectId: $projectId
);
}
// Content edits - just record that it happened, not the diff
if ($updated->description !== $existing->description) {
$this->auditRepo->storeEvent(
action: 'article.edit',
values: '',
entity: 'article',
entityId: $articleId,
userId: $userId,
projectId: $projectId
);
}
}
public function clearWikiCache()
{
session()->forget('lastArticle');
session()->forget('currentWiki');
}
}

View File

@@ -0,0 +1,293 @@
@extends($layout)
@section('content')
@php
$currentArticle = $article ?? null;
$wikiHL = $wikiHeadlines ?? [];
$wikiHeadlines = [];
function createTree($id, $parentId, &$wikiHeadlines, &$wikiHL, $indent)
{
$articles = array_filter($wikiHL, function ($v) use ($parentId) {
return $v->parent == $parentId;
});
if (count($articles) > 0) {
usort($articles, function ($a1, $a2) {
return $a1->title > $a2->title;
});
if ($parentId != null) {
$indent = $indent . '-';
}
foreach ($articles as $article) {
if ($article->id != $id) {
$art = $article;
$art->title = $indent . $article->title;
$wikiHeadlines[] = $art;
createTree($id, $article->id, $wikiHeadlines, $wikiHL, $indent);
}
}
}
}
if (!isset($_GET['closeModal'])) {
echo $tpl->displayNotification();
}
$id = '';
if (isset($currentArticle->id)) {
$id = $currentArticle->id;
}
// Populates the options tree
createTree($id, null, $wikiHeadlines, $wikiHL, '');
@endphp
<form class="formModal" method="post" action="{{ CURRENT_URL }}">
<div class="row">
<div class="col-md-2">
<div class="row-fluid marginBottom">
<h4 class="widgettitle title-light">
<span class="fa fa-folder"></span>{!! __('subtitles.organization') !!}
</h4>
<label>Parent</label>
<select name="parent" style="width:100%;">
<option value="0">None</option>
@foreach ($wikiHeadlines as $parent)
@if ($id != $parent->id)
<option value="{{ $parent->id }}"
{{ ($parent->id == $currentArticle->parent) ? "selected='selected'" : '' }} >{{ $tpl->escape($parent->title) }}</option>
@endif
@endforeach
</select>
<label>{!! __('label.status') !!}</label>
<select name="status" style="width:100%;">
<option value="draft" {{ $currentArticle->status == 'draft' ? "selected='selected'" : '' }}>{!! __('label.draft') !!}</option>
<option value="published" {{ $currentArticle->status == 'published' ? "selected='selected'" : '' }}>{!! __('label.published') !!}</option>
</select>
</div>
@if ($id !== '')
<h4 class="widgettitle title-light"><span class="fa fa-link"></span> {!! __('headlines.linked_milestone') !!} <i class="fa fa-question-circle-o helperTooltip" data-tippy-content="{{ __('tooltip.link_milestones_tooltip') }}"></i></h4>
<ul class="sortableTicketList" style="width:99%">
@if ($currentArticle->milestoneId == '')
<li class="ui-state-default center" id="milestone_0">
<h4>{!! __('headlines.no_milestone_link') !!}</h4>
{!! __('text.use_milestone_to_track_leancanvas') !!}<br />
<div class="row" id="milestoneSelectors">
@if ($login::userIsAtLeast($roles::$editor))
<div class="col-md-12">
<a href="javascript:void(0);" onclick="leantime.leanCanvasController.toggleMilestoneSelectors('new');">{!! __('links.create_link_milestone') !!}</a>
| <a href="javascript:void(0);" onclick="leantime.leanCanvasController.toggleMilestoneSelectors('existing');">{!! __('links.link_existing_milestone') !!}</a>
</div>
@endif
</div>
<div class="row" id="newMilestone" style="display:none;">
<div class="col-md-12">
<x-global::forms.textarea name="newMilestone"></x-global::forms.textarea><br />
<input type="hidden" name="type" value="milestone" />
<input type="hidden" name="leancanvasitemid" value="{{ $id }} " />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryArticleSubmitButton').click()" contentRole="primary" />
<x-global::forms.button tag="a" link="javascript:void(0);" onclick="leantime.leanCanvasController.toggleMilestoneSelectors('hide');" contentRole="tertiary">
<i class="fas fa-times"></i> {!! __('links.cancel') !!}
</x-global::forms.button>
</div>
</div>
<div class="row" id="existingMilestone" style="display:none;">
<div class="col-md-12">
<select data-placeholder="{{ __('input.placeholders.filter_by_milestone') }}" name="existingMilestone" class="user-select">
<option value="">{!! __('label.all_milestones') !!}</option>
@foreach ($milestones as $milestoneRow)
<option value="{{ $milestoneRow->id }}"
@if (isset($searchCriteria['milestone']) && ($searchCriteria['milestone'] == $milestoneRow->id))
selected='selected'
@endif
>{{ $milestoneRow->headline }}</option>
@endforeach
</select>
<input type="hidden" name="type" value="milestone" />
<input type="hidden" name="articleId" value="{{ $id }} " />
<x-global::forms.button tag="input" inputType="button" labelText="Save" onclick="jQuery('#primaryArticleSubmitButton').click()" contentRole="primary" />
<x-global::forms.button tag="a" link="javascript:void(0);" onclick="leantime.leanCanvasController.toggleMilestoneSelectors('hide');" contentRole="tertiary">
<i class="fas fa-times"></i> {!! __('links.cancel') !!}
</x-global::forms.button>
</div>
</div>
</li>
@else
<li class="ui-state-default" id="milestone_{{ $currentArticle->milestoneId }}" class="leanCanvasMilestone" >
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $currentArticle->milestoneId }}">
<div class="htmx-indicator">
{!! __('label.loading_milestone') !!}
</div>
</div>
<x-global::forms.button tag="a" link="{{ CURRENT_URL }}?removeMilestone={{ $currentArticle->milestoneId }}" class="formModal" state="danger" variant="outline"><i class="fa fa-close"></i> {!! __('links.remove') !!}</x-global::forms.button>
</li>
@endif
</ul>
@endif
<br />
</div>
<div class="col-md-8">
<div class="btn-group inlineDropDownContainerLeft">
<button data-selected="graduation-cap" type="button"
class="icp icp-dd btn btn-default dropdown-toggle iconpicker-container titleIconPicker"
data-toggle="dropdown">
<span class="iconPlaceholder">
<i class="fa fa-file"></i>
</span>
<span class="caret"></span>
</button>
<div class="dropdown-menu"></div>
</div>
<input type="hidden" class="articleIcon" value="{{ $currentArticle->data }}" name="articleIcon"/>
<x-global::forms.text-input variant="headline" name="title" value="{{ $tpl->escape($currentArticle->title) }}" placeholder="{{ __('input.placeholders.wiki_title') }}" style="width:80%" />
<br />
<input type="text" value="{{ $tpl->escape($currentArticle->tags) }}" name="tags" id="tags" />
<textarea class="tiptapComplex" rows="20" cols="80" id="wikiArticleContentEditor" name="description">{{ $currentArticle->description ?? '' }}</textarea>
<div class="row">
<div class="col-md-10 padding-top-sm">
<br />
<input type="hidden" name="saveTicket" value="1" />
<input type="hidden" id="saveAndCloseButton" name="saveAndCloseArticle" value="0" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="saveArticle" id="primaryArticleSubmitButton" />
<x-global::forms.button tag="input" inputType="submit" contentRole="secondary" name="saveAndCloseArticle" onclick="jQuery('#saveAndCloseButton').val('1');" :labelText="__('buttons.save_and_close')" />
</div>
<div class="col-md-2 align-right padding-top-sm">
@if (isset($currentArticle->id) && $currentArticle->id != '' && $login::userIsAtLeast($roles::$editor))
<br />
<x-global::forms.button tag="a" link="#/wiki/delArticle/{{ $currentArticle->id }}" class="delete" state="danger" variant="outline"><i class="fa fa-trash"></i> {!! __('links.delete_article') !!}</x-global::forms.button>
@endif
</div>
</div>
</div>
<div class="col-md-2"></div>
</div>
</form>
@once
@push('scripts')
<script>
jQuery(document).ready(function(){
// Initialize Tiptap complex editor
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initComplexEditor();
}
@if (isset($_GET['closeModal']))
jQuery.nmTop().close();
@endif
jQuery('.iconpicker-container').iconpicker({
component:'.btn > .iconPlaceholder',
input:'.articleIcon',
inputSearch: true,
defaultValue:"far fa-file-alt",
selected: "{{ $currentArticle->data }}",
showFooter: false,
searchInFooter: false,
icons: [
{title: "far fa-file-alt", searchTerms:['icons']},
{title: "fab fa-accessible-icon", searchTerms:['icons']},
{title: "far fa-address-book", searchTerms:['icons']},
{title: "fas fa-archive", searchTerms:['icons']},
{title: "fas fa-asterisk", searchTerms:['icons']},
{title: "fas fa-balance-scale", searchTerms:['icons']},
{title: "fas fa-ban", searchTerms:['icons']},
{title: "fas fa-bell", searchTerms:['icons']},
{title: "fas fa-binoculars", searchTerms:['icons']},
{title: "fas fa-birthday-cake", searchTerms:['icons']},
{title: "fas fa-bolt", searchTerms:['icons']},
{title: "fas fa-book", searchTerms:['icons']},
{title: "fas fa-bookmark", searchTerms:['icons']},
{title: "fas fa-briefcase", searchTerms:['icons']},
{title: "fas fa-bug", searchTerms:['icons']},
{title: "far fa-building", searchTerms:['icons']},
{title: "fas fa-bullhorn", searchTerms:['icons']},
{title: "far fa-calendar-alt", searchTerms:['icons']},
{title: "fas fa-chart-bar", searchTerms:['icons']},
{title: "fas fa-check-circle", searchTerms:['icons']},
{title: "fas fa-chart-line", searchTerms:['icons']},
{title: "fas fa-chess", searchTerms:['icons']},
{title: "fas fa-cogs", searchTerms:['icons']},
{title: "fas fa-comments", searchTerms:['icons']},
{title: "fas fa-compass", searchTerms:['icons']},
{title: "fas fa-database", searchTerms:['icons']},
{title: "fas fa-envelope", searchTerms:['icons']},
{title: "fas fa-exclamation-triangle", searchTerms:['icons']},
{title: "fas fa-flask", searchTerms:['icons']},
{title: "fas fa-globe", searchTerms:['icons']},
{title: "fas fa-gem", searchTerms:['icons']},
{title: "fas fa-graduation-cap", searchTerms:['icons']},
{title: "fas fa-hand-spock", searchTerms:['icons']},
{title: "fas fa-heart", searchTerms:['icons']},
{title: "fas fa-home", searchTerms:['icons']},
{title: "fas fa-image", searchTerms:['icons']},
{title: "fas fa-info-circle", searchTerms:['icons']},
{title: "fas fa-key", searchTerms:['icons']},
{title: "fas fa-leaf", searchTerms:['icons']},
{title: "fas fa-life-ring", searchTerms:['icons']},
{title: "fas fa-lightbulb", searchTerms:['icons']},
{title: "fas fa-link", searchTerms:['icons']},
{title: "fas fa-location-arrow", searchTerms:['icons']},
{title: "fas fa-lock", searchTerms:['icons']},
{title: "fas fa-map", searchTerms:['icons']},
{title: "fas fa-map-signs", searchTerms:['icons']},
{title: "fas fa-money-bill-alt", searchTerms:['icons']},
{title: "fas fa-paper-plane", searchTerms:['icons']},
{title: "fas fa-paperclip", searchTerms:['icons']},
{title: "fas fa-question-circle", searchTerms:['icons']},
{title: "fas fa-quote-left", searchTerms:['icons']},
{title: "fas fa-road", searchTerms:['icons']},
{title: "fas fa-rocket", searchTerms:['icons']},
{title: "fas fa-shopping-cart", searchTerms:['icons']},
{title: "fas fa-sitemap", searchTerms:['icons']},
{title: "fas fa-sliders-h", searchTerms:['icons']},
{title: "fas fa-star", searchTerms:['icons']},
{title: "fas fa-tachometer-alt", searchTerms:['icons']},
{title: "fas fa-thermometer-half", searchTerms:['icons']},
{title: "fas fa-thumbs-down", searchTerms:['icons']},
{title: "fas fa-thumbs-up", searchTerms:['icons']},
{title: "fas fa-trash-alt", searchTerms:['icons']},
{title: "fas fa-trophy", searchTerms:['icons']},
{title: "fas fa-user-circle", searchTerms:['icons']},
{title: "fas fa-utensils", searchTerms:['icons']}
]
});
jQuery('.iconpicker-container').on('iconpickerSelected', function(event){
jQuery(".articleIcon").val(event.iconpickerValue);
});
leantime.ticketsController.initTagsInput();
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,17 @@
@extends($layout)
@section('content')
@php
$ticket = $ticket ?? null;
@endphp
<h4 class="widgettitle title-light"><i class="fa fa-trash"></i> {!! __('buttons.delete') !!}</h4>
<form method="post" action="{{ BASE_URL }}/wiki/delArticle/{{ (int) $_GET['id'] }}">
<p>{!! __('text.are_you_sure_delete_article') !!}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/wiki/show/">{!! __('buttons.back') !!}</x-global::forms.button>
</form>
@endsection

View File

@@ -0,0 +1,13 @@
@extends($layout)
@section('content')
<h4 class="widgettitle title-light"><i class="fa fa-trash"></i> {!! __('buttons.delete') !!}</h4>
<form method="post" action="{{ BASE_URL }}/wiki/delWiki/{{ $tpl->escape($_GET['id']) }}">
<p>{!! __('text.are_you_sure_delete_wiki') !!}</p>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/wiki/show">{!! __('buttons.back') !!}</x-global::forms.button>
</form>
@endsection

View File

@@ -0,0 +1,107 @@
@php
/** @var array $activity */
/** @var int $articleId */
$actionIcons = [
'article.create' => 'fa fa-plus',
'article.edit' => 'fa fa-edit',
'article.title' => 'fa fa-heading',
'article.status' => 'fa fa-circle-dot',
'article.parent' => 'fa fa-folder-tree',
'article.milestone' => 'fa fa-flag',
'article.tags' => 'fa fa-tags',
'article.icon' => 'fa fa-icons',
];
$actionClasses = [
'article.create' => '',
'article.edit' => 'edit',
'article.title' => 'edit',
'article.status' => 'status',
'article.parent' => '',
'article.milestone' => '',
'article.tags' => 'edit',
'article.icon' => '',
];
/**
* Build a natural-language label based on the action and its values.
*/
function getActivityLabel(string $action, array $values): string
{
switch ($action) {
case 'article.create':
return 'created the article';
case 'article.edit':
return 'edited document text';
case 'article.title':
$to = $values['to'] ?? '';
return $to !== '' ? 'renamed the article to "' . e($to) . '"' : 'renamed the article';
case 'article.status':
$to = $values['to'] ?? '';
if ($to === 'published') {
return 'published the article';
} elseif ($to === 'draft') {
return 'reverted to draft';
}
return 'changed the status';
case 'article.parent':
$to = $values['to'] ?? '';
if (empty($to) || $to === '0') {
return 'removed the parent article';
}
return 'added a parent article';
case 'article.milestone':
$to = $values['to'] ?? '';
if (empty($to) || $to === '0') {
return 'removed the milestone';
}
return 'added a milestone';
case 'article.tags':
return 'updated tags';
case 'article.icon':
return 'changed the icon';
default:
return 'updated the article';
}
}
@endphp
<div class="wiki-activity-feed" id="wikiActivityFeed">
@forelse ($activity as $item)
@php
$action = $item['action'] ?? '';
$icon = $actionIcons[$action] ?? 'fa fa-circle';
$cssClass = $actionClasses[$action] ?? '';
$name = trim(($item['firstname'] ?? '') . ' ' . ($item['lastname'] ?? ''));
$date = $item['date'] ?? '';
$values = $item['values'] ?? [];
$label = getActivityLabel($action, $values);
@endphp
<div class="wiki-activity-item">
<div class="wiki-activity-icon {{ $cssClass }}">
<i class="{{ $icon }}"></i>
</div>
<div class="wiki-activity-content">
<div class="wiki-activity-text">
<strong>{{ $name ?: 'Someone' }}</strong> {{ $label }}
</div>
@if (!empty($date))
<div class="wiki-activity-time">{{ format($date)->date() }}</div>
@endif
</div>
</div>
@empty
<div class="wiki-activity-empty">
<span>No activity yet</span>
</div>
@endforelse
</div>

View File

@@ -0,0 +1,883 @@
@extends($layout)
@section('content')
@php
$wikis = $wikis ?? [];
$wikiHeadlines = $wikiHeadlines ?? [];
$milestones = $milestones ?? [];
$currentWiki = $currentWiki ?? false;
$currentArticle = $currentArticle ?? null;
/**
* Creates a modern tree view for wiki navigation
*/
function createModernTreeView($array, $currentParent, $currentArticleId, int $currLevel = 0, ?\Leantime\Core\UI\Template $tplObject = null): void
{
$hasChildren = false;
foreach ($array as $headline) {
if ((int) $currentParent === (int) $headline->parent) {
if (! $hasChildren) {
echo '<ul class="wiki-tree">';
$hasChildren = true;
}
$isActive = ($currentArticleId == $headline->id) ? ' active' : '';
$isDraft = ($headline->status == 'draft');
echo '<li class="wiki-tree-item">';
echo '<a href="' . BASE_URL . '/wiki/show/' . $headline->id . '" class="wiki-tree-link' . $isActive . '">';
echo '<i class="' . $tplObject->escape($headline->data) . '"></i>';
echo '<span>' . $tplObject->escape($headline->title) . '</span>';
if ($isDraft) {
echo ' <span class="wiki-tree-draft">(' . $tplObject->__('label.draft') . ')</span>';
}
echo '</a>';
createModernTreeView($array, $headline->id, $currentArticleId, $currLevel + 1, $tplObject);
echo '</li>';
}
}
if ($hasChildren) {
echo '</ul>';
}
}
// Get author initials for avatar
$authorInitials = '';
if ($currentArticle && ! empty($currentArticle->firstname)) {
$authorInitials .= strtoupper(substr($currentArticle->firstname, 0, 1));
if (! empty($currentArticle->lastname)) {
$authorInitials .= strtoupper(substr($currentArticle->lastname, 0, 1));
}
}
@endphp
<div class="pageheader">
<div class="pageicon"><span class="fa fa-book"></span></div>
<div class="pagetitle">
@if(count($wikis) > 0)
<x-global::subjectSwitcher
:parent="__('headlines.documents')"
:current="$currentWiki !== false ? $currentWiki->title : __('label.select_board')">
<li><a class="inlineEdit" href="#/wiki/wikiModal/">{!! __('link.new_wiki') !!}</a></li>
<li class='nav-header border'></li>
@foreach($wikis as $wiki)
<li>
<a href="{{ BASE_URL . '/wiki/show?setWiki=' . $wiki->id }}">{{ $wiki->title }}</a>
</li>
@endforeach
</x-global::subjectSwitcher>
@else
<h1>{!! __('headlines.documents') !!}</h1>
@endif
</div>
{{-- Header rule (2026-08-03): actions sits in the right cluster,
vertically centered never floated inside the title block. --}}
@if(count($wikis) > 0)
<div class="pageheader-right">
<span class="dropdown dropdownWrapper headerEditDropdown">
<a href="javascript:void(0)" class="dropdown-toggle btn btn-transparent" data-toggle="dropdown"><i class="fa-solid fa-ellipsis-v"></i></a>
<ul class="dropdown-menu editCanvasDropdown">
@if($login::userIsAtLeast($roles::$editor) && $currentWiki)
<li><a class="inlineEdit" href="#/wiki/wikiModal/{{ $currentWiki->id }}">{!! __('link.edit_wiki') !!}</a></li>
<li><a class="delete" href="#/wiki/delWiki/{{ $currentWiki->id }}"><i class="fa fa-trash"></i> {!! __('links.delete_wiki') !!}</a></li>
@endif
</ul>
</span>
</div>
@endif
</div>
<div class="maincontent">
{!! $tpl->displayNotification() !!}
@if((! $currentArticle || $currentArticle->id != null) && (! $wikis || count($wikis) == 0))
<!-- No wikis exist - show empty state -->
<div class="wiki-empty-state">
<div class="wiki-empty-state-icon svgContainer">
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_book_reading_re_fu2c.svg') !!}
</div>
<h3 class="wiki-empty-state-title">{!! __('headlines.no_articles_yet') !!}</h3>
<p class="wiki-empty-state-text">{!! __('text.create_new_wiki') !!}</p>
<x-global::forms.button tag="a" link="#/wiki/wikiModal/" class="inlineEdit" contentRole="primary">{!! __('links.icon.create_new_board') !!}</x-global::forms.button>
</div>
@elseif($wikis && count($wikis) > 0)
@if($currentArticle && $currentArticle->id != null)
<!-- Single Panel Layout: Contents | Document | Properties (all inside) -->
<div class="wiki-layout">
<!-- Main Content Area (contains everything) -->
<main class="wiki-content">
<!-- Three-panel layout inside -->
<div class="wiki-content-layout">
<!-- Left: Contents Sidebar -->
<div class="wiki-contents-panel" id="contentsPanel">
<div class="wiki-panel-header">
<h4 class="widgettitle title-light"><i class="fa fa-list"></i> Contents</h4>
<button class="wiki-collapse-btn" id="toggleContents" title="Collapse">
<i class="fa fa-chevron-left"></i>
</button>
</div>
<nav id="article-toc-wrapper">
@php createModernTreeView($wikiHeadlines, 0, $currentArticle->id, 0, $tpl); @endphp
</nav>
@if($login::userIsAtLeast($roles::$editor))
<button class="wiki-create-btn"
hx-post="{{ BASE_URL }}/hx/wiki/articleContent/create"
hx-swap="none">
<i class="fa fa-plus"></i>
<span>{!! __('link.create_article') !!}</span>
</button>
@endif
</div>
<!-- Toggle for collapsed Contents -->
<button class="wiki-panel-toggle left" id="showContentsBtn" title="Show Contents">
<i class="fa fa-chevron-right"></i>
</button>
<div class="wiki-content-inner">
<!-- Toggle for collapsed Details -->
<button class="wiki-panel-toggle right" id="showPropertiesBtn" title="Show Details">
<i class="fa fa-chevron-left"></i>
</button>
<!-- Document Header -->
<header class="wiki-document-header">
@if($login::userIsAtLeast($roles::$editor))
<!-- Editable Title with Icon Picker -->
<div class="form-group" id="wikiTitleWrapper">
<div class="btn-group inlineDropDownContainerLeft">
<button data-selected="graduation-cap" type="button"
class="icp icp-dd btn btn-default dropdown-toggle iconpicker-container titleIconPicker"
data-toggle="dropdown">
<span class="iconPlaceholder"><i class="{{ e($currentArticle->data ?: 'fa fa-file-alt') }}"></i></span>
<span class="caret"></span>
</button>
<div class="dropdown-menu"></div>
</div>
<input type="hidden" id="wikiArticleIcon" class="articleIcon" value="{{ e($currentArticle->data) }}" />
<x-global::forms.text-input
id="wikiTitleEditable"
variant="headline"
value="{{ e($currentArticle->title) }}"
data-original="{{ e($currentArticle->title) }}"
placeholder="{{ __('input.placeholders.wiki_title') }}"
style="width:80%" autocomplete="off" />
</div>
<!-- Editable Tags -->
<div class="wiki-tags-wrapper">
<input type="text"
id="wikiTagsInput"
class="wiki-tags-input"
data-role="tagsinput"
value="{{ e($currentArticle->tags ?? '') }}"
placeholder="Add tags..." />
</div>
@else
<h1 class="wiki-document-title">
<i class="article-icon {{ e($currentArticle->data) }}"></i>
{{ $currentArticle->title }}
</h1>
@php $tagsArray = array_filter(explode(',', $currentArticle->tags ?? '')); @endphp
@if(count($tagsArray) > 0)
<div class="wiki-document-tags">
@foreach($tagsArray as $tag)
@php $tag = trim($tag); @endphp
@if(! empty($tag))
<span class="wiki-document-tag">{{ $tag }}</span>
@endif
@endforeach
</div>
@endif
@endif
</header>
<!-- Document Body - Click to Edit -->
<div class="wiki-document-wrapper" id="wikiDocumentWrapper" data-article-id="{{ $currentArticle->id }}">
@if($login::userIsAtLeast($roles::$editor))
<!-- Hidden textarea for Tiptap -->
<textarea id="wikiArticleContent" class="wiki-editor-textarea" style="display:none;">{!! $tpl->escapeMinimal($currentArticle->description) !!}</textarea>
<!-- Tiptap editor will be initialized here -->
<div id="wikiTiptapEditor" class="wiki-document"></div>
<!-- Edit mode indicator -->
<div class="wiki-edit-indicator" id="wikiEditIndicator" style="display: none;">
<i class="fa fa-circle"></i>
<span>Editing</span>
</div>
@else
<!-- Read-only view for non-editors -->
<article class="wiki-document" id="wikiDocumentContent">
{!! $tpl->escapeMinimal($currentArticle->description) !!}
</article>
@endif
</div>
@if(! empty($currentArticle->milestoneHeadline))
<div class="wiki-milestone-card">
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $currentArticle->milestoneId }}">
<div class="htmx-indicator">
{!! __('label.loading_milestone') !!}
</div>
</div>
</div>
@endif
<!-- Comments Section -->
<section class="wiki-comments-section" id="comments">
<h4 class="widgettitle title-light"><span class="fa-solid fa-comments"></span> {!! __('subtitles.discussion') !!}</h4>
<form method="post" action="{{ BASE_URL }}/wiki/show/{{ $currentArticle->id }}#comment">
<input type="hidden" name="comment" value="1" />
@include('comments::submodules.generalComment', ['formUrl' => BASE_URL . '/wiki/show/' . $currentArticle->id])
</form>
</section>
</div><!-- /.wiki-content-inner -->
<!-- Properties Panel (inside content area) -->
<div class="wiki-properties-panel" id="propertiesPanel">
<div class="wiki-panel-header">
<h4 class="widgettitle title-light"><i class="fa fa-info-circle"></i> Details</h4>
<button class="wiki-collapse-btn" id="collapseProperties" title="Collapse">
<i class="fa fa-chevron-right"></i>
</button>
</div>
<!-- Properties Section -->
<div class="wiki-properties-section">
<!-- Status Dropdown -->
<div class="form-group">
<label class="control-label">{!! __('label.status') !!}</label>
<div class="">
@if($login::userIsAtLeast($roles::$editor))
<select id="wikiStatusSelect" class="span11">
<option value="draft" @selected($currentArticle->status === 'draft')>Draft</option>
<option value="published" @selected($currentArticle->status !== 'draft')>Published</option>
</select>
@else
{{ ucfirst($currentArticle->status) }}
@endif
</div>
</div>
@php
// Find parent article name
$parentName = 'None';
if ($currentArticle->parent && $currentArticle->parent > 0) {
foreach ($wikiHeadlines as $headline) {
if ($headline->id == $currentArticle->parent) {
$parentName = e($headline->title);
break;
}
}
}
@endphp
<!-- Parent -->
<div class="form-group">
<label class="control-label">Parent</label>
<div class="">
@if($login::userIsAtLeast($roles::$editor))
@php
$parentOptions = array_filter($wikiHeadlines, function ($h) use ($currentArticle) {
return $h->id != $currentArticle->id;
});
@endphp
<select id="wikiParentSelect" class="span11">
<option value="0" @selected(! $currentArticle->parent || $currentArticle->parent == 0)>None</option>
@foreach($parentOptions as $headline)
<option value="{{ $headline->id }}" @selected($currentArticle->parent == $headline->id)>{{ $headline->title }}@if($headline->status === 'draft') ({!! __('label.draft') !!})@endif</option>
@endforeach
</select>
@else
@if($currentArticle->parent && $currentArticle->parent > 0)
<a href="{{ BASE_URL }}/wiki/show/{{ $currentArticle->parent }}">
{{ $parentName }}
</a>
@else
<span>{{ $parentName }}</span>
@endif
@endif
</div>
</div>
<!-- Milestone -->
<div class="form-group">
<label class="control-label">{!! __('label.milestone') !!}</label>
<div class="">
@if($login::userIsAtLeast($roles::$editor))
<select id="wikiMilestoneSelect" class="span11">
<option value="">{!! __('label.not_assigned_to_milestone') !!}</option>
@foreach($milestones as $milestone)
<option value="{{ $milestone->id }}" @selected($currentArticle->milestoneId == $milestone->id)>{{ $milestone->headline }}</option>
@endforeach
</select>
@else
@if(! empty($currentArticle->milestoneHeadline))
<a href="{{ BASE_URL }}/tickets/roadmap#/tickets/editMilestone/{{ $currentArticle->milestoneId }}">
{{ $currentArticle->milestoneHeadline }}
</a>
@else
<span>{!! __('label.not_assigned_to_milestone') !!}</span>
@endif
@endif
</div>
</div>
<!-- Author -->
<div class="form-group">
<label class="control-label">{!! __('label.author') !!}</label>
<div class="">
<div class="wiki-author">
<span class="wiki-author-avatar">{{ $authorInitials }}</span>
{{ $currentArticle->firstname }} {{ $currentArticle->lastname }}
</div>
</div>
</div>
<!-- Last Saved -->
<div class="form-group">
<label class="control-label">{!! __('label.last_updated') !!}</label>
<div class="" id="wikiLastSaved" data-timestamp="{{ $currentArticle->modified }}">
{{ format($currentArticle->modified)->diffForHumans() }}
</div>
</div>
</div>
<!-- Activity Section -->
<div class="wiki-properties-section wiki-activity-section">
<h4 class="widgettitle title-light"><i class="fa fa-clock-rotate-left"></i> Activity</h4>
<div id="wikiActivityContainer"
hx-get="{{ BASE_URL }}/hx/wiki/articleActivity?articleId={{ $currentArticle->id }}"
hx-trigger="load, refreshActivity from:body"
hx-swap="innerHTML">
<div class="wiki-activity-loading">
<i class="fa fa-circle-notch fa-spin"></i> Loading activity...
</div>
</div>
</div>
<!-- Delete (pinned to bottom) -->
@if($login::userIsAtLeast($roles::$editor))
<div class="wiki-properties-footer">
<x-global::forms.button tag="a" link="#/wiki/delArticle/{{ $currentArticle->id }}" class="wiki-action-btn delete" state="danger" variant="outline">
<i class="fa fa-trash"></i> Delete Article
</x-global::forms.button>
</div>
@endif
</div><!-- /.wiki-properties-panel -->
</div><!-- /.wiki-content-layout -->
</main>
</div>
@else
<!-- Wiki exists but no articles yet -->
<div class="wiki-empty-state">
<div class="wiki-empty-state-icon svgContainer" style="width: 200px; margin: 0 auto;">
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_book_reading_re_fu2c.svg') !!}
</div>
<h3 class="wiki-empty-state-title">{!! __('headlines.no_articles_yet') !!}</h3>
<p class="wiki-empty-state-text">{!! __('text.create_new_content') !!}</p>
<x-global::forms.button contentRole="primary"
hx-post="{{ BASE_URL }}/hx/wiki/articleContent/create"
hx-swap="none">
<i class="fa fa-plus"></i> {!! __('link.create_article') !!}
</x-global::forms.button>
</div>
@endif
@endif
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function() {
jQuery('#toggleContents').on('click', function() {
var panel = jQuery('#contentsPanel');
var showBtn = jQuery('#showContentsBtn');
panel.addClass('collapsed');
showBtn.addClass('visible');
localStorage.setItem('wikiContentsCollapsed', 'true');
});
jQuery('#showContentsBtn').on('click', function() {
var panel = jQuery('#contentsPanel');
var showBtn = jQuery('#showContentsBtn');
panel.removeClass('collapsed');
showBtn.removeClass('visible');
localStorage.setItem('wikiContentsCollapsed', 'false');
});
jQuery('#collapseProperties').on('click', function() {
var panel = jQuery('#propertiesPanel');
var showBtn = jQuery('#showPropertiesBtn');
panel.addClass('collapsed');
showBtn.addClass('visible');
localStorage.setItem('wikiPropertiesCollapsed', 'true');
});
jQuery('#showPropertiesBtn').on('click', function() {
var panel = jQuery('#propertiesPanel');
var showBtn = jQuery('#showPropertiesBtn');
panel.removeClass('collapsed');
showBtn.removeClass('visible');
localStorage.setItem('wikiPropertiesCollapsed', 'false');
});
var isSmallScreen = window.innerWidth <= 1280;
if (isSmallScreen || localStorage.getItem('wikiContentsCollapsed') === 'true') {
jQuery('#contentsPanel').addClass('collapsed');
jQuery('#showContentsBtn').addClass('visible');
}
if (isSmallScreen || localStorage.getItem('wikiPropertiesCollapsed') === 'true') {
jQuery('#propertiesPanel').addClass('collapsed');
jQuery('#showPropertiesBtn').addClass('visible');
}
var resizeTimeout;
jQuery(window).on('resize', function() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(function() {
var nowSmall = window.innerWidth <= 1280;
if (nowSmall) {
jQuery('#contentsPanel').addClass('collapsed');
jQuery('#showContentsBtn').addClass('visible');
jQuery('#propertiesPanel').addClass('collapsed');
jQuery('#showPropertiesBtn').addClass('visible');
}
}, 150);
});
@if($currentArticle && $login::userIsAtLeast($roles::$editor))
(function() {
var articleId = @json($currentArticle->id);
var wrapper = document.getElementById('wikiDocumentWrapper');
var editorEl = document.getElementById('wikiTiptapEditor');
var textarea = document.getElementById('wikiArticleContent');
var indicator = document.getElementById('wikiEditIndicator');
if (!editorEl || !textarea || !window.leantime || !window.leantime.tiptapController) {
console.warn('[Wiki] Tiptap controller not available');
return;
}
var isEditing = false;
var saveTimeout = null;
var lastSavedContent = textarea.value;
var toolbarClicking = false;
wrapper.addEventListener('mousedown', function(e) {
if (isEditing && !editorEl.contains(e.target)) {
toolbarClicking = true;
}
});
var tiptapInstance = leantime.tiptapController.initComplex(textarea, {
placeholder: 'Click anywhere to start editing...',
toolbar: false,
autosave: false,
onCreate: function(params) {
params.editor.setEditable(false);
},
onUpdate: function(params) {
if (isEditing) {
clearTimeout(saveTimeout);
showIndicator('saving');
saveTimeout = setTimeout(function() {
saveContent(params.editor.getHTML());
}, 1500);
}
},
onBlur: function(params) {
if (toolbarClicking) {
toolbarClicking = false;
return;
}
setTimeout(function() {
if (!isEditing) return;
var active = document.activeElement;
if (wrapper.contains(active)) return;
var openPopover = document.querySelector(
'.tiptap-color-popover, .tiptap-font-popover, .tiptap-heading-popover, .tiptap-image-popover'
);
if (openPopover) return;
exitEditMode();
}, 300);
}
});
if (!tiptapInstance) {
console.error('[Wiki] Failed to initialize Tiptap');
return;
}
var editor = tiptapInstance.editor;
function showToolbar() {
if (window.leantime.tiptapToolbar) {
var toolbar = window.leantime.tiptapToolbar.create(editor, 'complex');
var tiptapEditorEl = wrapper.querySelector('.tiptap-editor');
window.leantime.tiptapToolbar.attach({ element: tiptapEditorEl || editorEl }, toolbar);
}
}
function hideToolbar() {
var toolbarEl = wrapper.querySelector('.tiptap-toolbar');
if (toolbarEl) {
toolbarEl.remove();
}
}
function enterEditMode() {
if (isEditing) return;
isEditing = true;
editor.setEditable(true);
wrapper.classList.add('editing');
showToolbar();
showIndicator('editing');
editor.commands.focus('end');
}
function exitEditMode() {
if (!isEditing) return;
var currentContent = editor.getHTML();
if (currentContent !== lastSavedContent) {
saveContent(currentContent);
}
isEditing = false;
editor.setEditable(false);
wrapper.classList.remove('editing');
hideToolbar();
hideIndicator();
}
function saveContent(content) {
showIndicator('saving');
fetch(leantime.appUrl + '/hx/wiki/articleContent/save?articleId=' + articleId, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'include',
body: 'description=' + encodeURIComponent(content)
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
lastSavedContent = content;
showIndicator('saved');
updateLastSaved();
setTimeout(function() {
if (!isEditing) hideIndicator();
}, 2000);
} else {
showIndicator('error');
}
})
.catch(function(err) {
console.error('[Wiki] Save failed:', err);
showIndicator('error');
});
}
function showIndicator(state) {
if (!indicator) return;
indicator.style.display = 'flex';
indicator.className = 'wiki-edit-indicator ' + state;
var icon = indicator.querySelector('i');
var text = indicator.querySelector('span');
switch (state) {
case 'editing': icon.className = 'fa fa-edit'; text.textContent = 'Editing'; break;
case 'saving': icon.className = 'fa fa-circle-notch fa-spin'; text.textContent = 'Saving...'; break;
case 'saved': icon.className = 'fa fa-check'; text.textContent = 'Saved'; break;
case 'error': icon.className = 'fa fa-exclamation-triangle'; text.textContent = 'Save failed'; break;
}
}
function hideIndicator() {
if (indicator) indicator.style.display = 'none';
}
wrapper.addEventListener('click', function(e) {
if (e.target.tagName === 'A' || e.target.closest('a')) return;
enterEditMode();
});
document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
e.preventDefault();
if (isEditing) exitEditMode(); else enterEditMode();
}
if (e.key === 'Escape' && isEditing) {
e.preventDefault();
exitEditMode();
}
if ((e.metaKey || e.ctrlKey) && e.key === 's' && isEditing) {
e.preventDefault();
saveContent(editor.getHTML());
}
});
window.addEventListener('beforeunload', function(e) {
if (isEditing) {
var currentContent = editor.getHTML();
if (currentContent !== lastSavedContent) {
navigator.sendBeacon(
leantime.appUrl + '/hx/wiki/articleContent/save?articleId=' + articleId,
new URLSearchParams({ description: currentContent })
);
}
}
});
})();
var titleEditable = document.getElementById('wikiTitleEditable');
if (titleEditable) {
var originalTitle = titleEditable.dataset.original;
titleEditable.addEventListener('blur', function() {
var newTitle = titleEditable.value.trim();
if (newTitle && newTitle !== originalTitle) {
saveField('title', newTitle, function() {
originalTitle = newTitle;
titleEditable.dataset.original = newTitle;
var treeLink = document.querySelector('.wiki-tree-link.active span');
if (treeLink) treeLink.textContent = newTitle;
updateLastSaved();
});
}
});
titleEditable.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); titleEditable.blur(); }
if (e.key === 'Escape') { titleEditable.value = originalTitle; titleEditable.blur(); }
});
}
var iconInput = document.getElementById('wikiArticleIcon');
if (iconInput && jQuery.fn.iconpicker) {
jQuery('.titleIconPicker').iconpicker({
component: '.btn > .iconPlaceholder',
input: '.articleIcon',
inputSearch: true,
defaultValue: 'far fa-file-alt',
selected: iconInput.value || 'far fa-file-alt',
showFooter: false,
searchInFooter: false,
icons: [
{title: "far fa-file-alt", searchTerms:['icons']},
{title: "fab fa-accessible-icon", searchTerms:['icons']},
{title: "far fa-address-book", searchTerms:['icons']},
{title: "fas fa-archive", searchTerms:['icons']},
{title: "fas fa-asterisk", searchTerms:['icons']},
{title: "fas fa-balance-scale", searchTerms:['icons']},
{title: "fas fa-ban", searchTerms:['icons']},
{title: "fas fa-bell", searchTerms:['icons']},
{title: "fas fa-binoculars", searchTerms:['icons']},
{title: "fas fa-birthday-cake", searchTerms:['icons']},
{title: "fas fa-bolt", searchTerms:['icons']},
{title: "fas fa-book", searchTerms:['icons']},
{title: "fas fa-bookmark", searchTerms:['icons']},
{title: "fas fa-briefcase", searchTerms:['icons']},
{title: "fas fa-bug", searchTerms:['icons']},
{title: "far fa-building", searchTerms:['icons']},
{title: "fas fa-bullhorn", searchTerms:['icons']},
{title: "far fa-calendar-alt", searchTerms:['icons']},
{title: "fas fa-chart-bar", searchTerms:['icons']},
{title: "fas fa-check-circle", searchTerms:['icons']},
{title: "fas fa-chart-line", searchTerms:['icons']},
{title: "fas fa-chess", searchTerms:['icons']},
{title: "fas fa-cogs", searchTerms:['icons']},
{title: "fas fa-comments", searchTerms:['icons']},
{title: "fas fa-compass", searchTerms:['icons']},
{title: "fas fa-database", searchTerms:['icons']},
{title: "fas fa-envelope", searchTerms:['icons']},
{title: "fas fa-exclamation-triangle", searchTerms:['icons']},
{title: "fas fa-flask", searchTerms:['icons']},
{title: "fas fa-globe", searchTerms:['icons']},
{title: "fas fa-gem", searchTerms:['icons']},
{title: "fas fa-graduation-cap", searchTerms:['icons']},
{title: "fas fa-hand-spock", searchTerms:['icons']},
{title: "fas fa-heart", searchTerms:['icons']},
{title: "fas fa-home", searchTerms:['icons']},
{title: "fas fa-image", searchTerms:['icons']},
{title: "fas fa-info-circle", searchTerms:['icons']},
{title: "fas fa-key", searchTerms:['icons']},
{title: "fas fa-leaf", searchTerms:['icons']},
{title: "fas fa-life-ring", searchTerms:['icons']},
{title: "fas fa-lightbulb", searchTerms:['icons']},
{title: "fas fa-link", searchTerms:['icons']},
{title: "fas fa-location-arrow", searchTerms:['icons']},
{title: "fas fa-lock", searchTerms:['icons']},
{title: "fas fa-map", searchTerms:['icons']},
{title: "fas fa-map-signs", searchTerms:['icons']},
{title: "fas fa-money-bill-alt", searchTerms:['icons']},
{title: "fas fa-paper-plane", searchTerms:['icons']},
{title: "fas fa-paperclip", searchTerms:['icons']},
{title: "fas fa-question-circle", searchTerms:['icons']},
{title: "fas fa-quote-left", searchTerms:['icons']},
{title: "fas fa-road", searchTerms:['icons']},
{title: "fas fa-rocket", searchTerms:['icons']},
{title: "fas fa-shopping-cart", searchTerms:['icons']},
{title: "fas fa-sitemap", searchTerms:['icons']},
{title: "fas fa-sliders-h", searchTerms:['icons']},
{title: "fas fa-star", searchTerms:['icons']},
{title: "fas fa-tachometer-alt", searchTerms:['icons']},
{title: "fas fa-thermometer-half", searchTerms:['icons']},
{title: "fas fa-thumbs-down", searchTerms:['icons']},
{title: "fas fa-thumbs-up", searchTerms:['icons']},
{title: "fas fa-trash-alt", searchTerms:['icons']},
{title: "fas fa-trophy", searchTerms:['icons']},
{title: "fas fa-user-circle", searchTerms:['icons']},
{title: "fas fa-utensils", searchTerms:['icons']}
]
});
jQuery('.titleIconPicker').on('iconpickerSelected', function(event) {
var newIcon = event.iconpickerValue;
jQuery('.articleIcon').val(newIcon);
jQuery('.titleIconPicker .iconPlaceholder > i').attr('class', newIcon);
saveField('icon', newIcon, function() {
var treeLink = document.querySelector('.wiki-tree-link.active i');
if (treeLink) treeLink.className = newIcon;
updateLastSaved();
});
});
}
var tagsInput = document.getElementById('wikiTagsInput');
if (tagsInput && jQuery.fn.tagsInput) {
jQuery('#wikiTagsInput').tagsInput({
width: '100%',
height: 'auto',
defaultText: 'Add tag...',
placeholderColor: 'var(--secondary-font-color)',
onChange: function(elem, elem_tags) {
// The tagsInput plugin passes only the single tag that changed as elem_tags, and
// fires once with `undefined` during its initial import. Saving elem_tags directly
// overwrote the column with just the last tag (and the literal "undefined" on load).
// Ignore the init call and persist the full delimited value instead.
if (typeof elem_tags === 'undefined') {
return;
}
saveField('tags', jQuery('#wikiTagsInput').val(), function() { updateLastSaved(); });
}
});
}
var statusSelect = document.getElementById('wikiStatusSelect');
if (statusSelect) {
statusSelect.addEventListener('change', function() {
var newStatus = statusSelect.value;
saveField('status', newStatus, function() {
var activeLink = document.querySelector('.wiki-tree-link.active');
if (activeLink) {
var draftLabel = activeLink.querySelector('.wiki-tree-draft');
if (newStatus === 'draft') {
if (!draftLabel) {
draftLabel = document.createElement('span');
draftLabel.className = 'wiki-tree-draft';
activeLink.appendChild(draftLabel);
}
draftLabel.textContent = '({{ __('label.draft') }})';
} else if (draftLabel) {
draftLabel.remove();
}
}
updateLastSaved();
});
});
}
var milestoneSelect = document.getElementById('wikiMilestoneSelect');
if (milestoneSelect) {
milestoneSelect.addEventListener('change', function() {
saveField('milestoneId', milestoneSelect.value, function() { window.location.reload(); });
});
}
var parentSelect = document.getElementById('wikiParentSelect');
if (parentSelect) {
parentSelect.addEventListener('change', function() {
saveField('parent', parentSelect.value, function() { window.location.reload(); });
});
}
function updateLastSaved() {
var lastSavedEl = document.getElementById('wikiLastSaved');
if (lastSavedEl) {
lastSavedEl.textContent = 'Just now';
lastSavedEl.dataset.timestamp = new Date().toISOString();
}
htmx.trigger(document.body, 'refreshActivity');
}
function saveField(field, value, onSuccess) {
var articleId = @json($currentArticle->id);
fetch(leantime.appUrl + '/hx/wiki/articleContent/save?articleId=' + articleId, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'include',
body: field + '=' + encodeURIComponent(value)
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
if (onSuccess) onSuccess(data);
} else {
console.error('[Wiki] Failed to save ' + field);
}
})
.catch(function(err) {
console.error('[Wiki] Save failed:', err);
});
}
@endif
@if($login::userHasRole([$roles::$commenter]))
leantime.commentsController.enableCommenterForms();
@endif
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,63 @@
@extends($layout)
@section('content')
@php
use Leantime\Domain\Wiki\Models\Template;
$today = date(__('language.dateformat'));
$author = session('userdata.name') . ' (' . session('userdata.mail') . ')';
// Document templates for the editor
// All Templates require title, description, content
$templates = [];
// All built-in document templates live as YAML in
// app/Domain/ContentTemplates/Library/wiki/ now. The registry block below
// loads them at request time, alongside any plugin-registered wiki
// templates. $today / $author are still in scope so plugins (via the
// documentTemplates filter, dispatched below) can keep using them.
// ── ContentTemplates registry — appliesTo:"wiki" ──
// Phase 3 of the content-templates rollout: plugins (and core) drop
// YAML files into ContentTemplates/wiki/ and they appear here.
//
// Each YAML string may carry t:KEY translation references; the
// TranslationResolver helper expands them at consume time so the
// user sees their locale's wording.
try {
$registry = app(\Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry::class);
foreach ($registry->forAppliesTo('wiki') as $contentTpl) {
$tplObj = app()->make(Template::class);
$tplObj->title = \Leantime\Domain\ContentTemplates\Support\TranslationResolver::resolve($contentTpl->title);
$tplObj->description = \Leantime\Domain\ContentTemplates\Support\TranslationResolver::resolve($contentTpl->description);
// Category — for wiki templates we reuse the existing `sector`
// field (same concept, different domain vocabulary). Falls back to
// "documents" when the YAML doesn't specify one, matching the
// hardcoded templates' previous default.
$tplObj->category = $contentTpl->sector !== null && $contentTpl->sector !== ''
? \Leantime\Domain\ContentTemplates\Support\TranslationResolver::resolve($contentTpl->sector)
: __('templates.documents');
$articles = (array) ($contentTpl->payload['articles'] ?? []);
// For single-article templates we mirror the legacy "one HTML blob"
// shape the editor expects. Multi-article wiki templates are out of
// scope for the editor's "insert template" flow (they'd map to wiki
// page creation, not editor insertion).
$tplObj->content = is_array($articles[0] ?? null)
? \Leantime\Domain\ContentTemplates\Support\TranslationResolver::resolve((string) ($articles[0]['content'] ?? ''))
: '';
if ($tplObj->content !== '') {
$templates[] = $tplObj;
}
}
} catch (\Throwable $e) {
// Registry not available (boot ordering, install) — silently skip.
}
$templates = $tpl->dispatch_filter('documentTemplates', $templates);
echo json_encode($templates);
@endphp
@endsection

View File

@@ -0,0 +1,68 @@
@extends($layout)
@section('content')
@php
$currentWiki = $wiki ?? null;
@endphp
<h4 class="widgettitle title-light"><i class="fa fa-book"></i> {!! __('label.wiki') !!} {{ $tpl->escape($currentWiki->title) }}</h4>
{!! $tpl->displayNotification() !!}
@php
$id = '';
if (isset($currentWiki->id)) {
$id = $currentWiki->id;
}
@endphp
<form class="formModal" method="post" action="{{ BASE_URL }}/wiki/wikiModal/{{ $id }}">
<label>{!! __('label.wiki_title') !!}</label>
<x-global::forms.text-input name="title" id="wikiTitle" value="{{ $tpl->escape($currentWiki->title) }}" placeholder="{{ __('input.placeholders.wiki_title') }}" /><br />
<br />
<div class="row">
<div class="col-md-6">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" id="saveBtn" />
</div>
<div class="col-md-6 align-right padding-top-sm">
@if (isset($currentWiki->id) && $currentWiki->id != '' && $login::userIsAtLeast($roles::$editor))
<a href="{{ BASE_URL }}/wiki/delWiki/{{ $currentWiki->id }}" class="delete formModal"><i class="fa fa-trash"></i> {!! __('links.delete_wiki') !!}</a>
@endif
</div>
</div>
</form>
@once
@push('scripts')
<script>
jQuery(document).ready(function(){
@if (isset($_GET['closeModal']))
jQuery.nmTop().close();
@endif
if(jQuery("#wikiTitle").val().length >= 2) {
jQuery("#saveBtn").removeAttr("disabled");
}else{
jQuery("#saveBtn").attr("disabled", "disabled");
}
jQuery("#wikiTitle").keypress(function(){
if(jQuery("#wikiTitle").val().length >= 2) {
jQuery("#saveBtn").removeAttr("disabled");
}else{
jQuery("#saveBtn").attr("disabled", "disabled");
}
})
});
</script>
@endpush
@endonce
@endsection