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,48 @@
<?php
namespace Leantime\Domain\ContentTemplates;
use Illuminate\Support\ServiceProvider;
use Leantime\Domain\ContentTemplates\Services\Appliers\CanvasItemsApplier;
use Leantime\Domain\ContentTemplates\Services\Appliers\WikiApplier;
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
/**
* Wires the ContentTemplates domain.
*
* The registry is bound as a singleton so library roots registered by plugins
* during their own boot persist across the request. Both built-in appliers
* (canvas items, wiki) self-register against the registry on boot so callers
* don't need to wire them up. Plugins can register additional appliers in
* their own register.php with $registry->registerApplier(...).
*/
class ContentTemplatesServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ContentTemplateRegistry::class);
$this->app->singleton(CanvasItemsApplier::class);
$this->app->singleton(WikiApplier::class);
}
public function boot(): void
{
/** @var ContentTemplateRegistry $registry */
$registry = $this->app->make(ContentTemplateRegistry::class);
// WikiApplier is registered first so it claims "wiki" before the
// catch-all CanvasItemsApplier. The registry uses one applier per
// appliesTo, so order matters here only because supports() on the
// canvas applier excludes 'wiki' explicitly — kept defensive.
$registry->registerApplier('wiki', $this->app->make(WikiApplier::class));
// CanvasItemsApplier is the catch-all for any non-wiki appliesTo. We
// can't enumerate every canvas type at boot (plugins add their own),
// so the registry consults supports() at apply time when a template's
// appliesTo isn't explicitly bound.
$canvasApplier = $this->app->make(CanvasItemsApplier::class);
foreach (['logicmodel', 'goal', 'leancanvas', 'swot'] as $canvasType) {
$registry->registerApplier($canvasType, $canvasApplier);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Leantime\Domain\ContentTemplates\Contracts;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
/**
* Applier — turns a ContentTemplate into rows in the target system.
*
* Registry holds DEFINITIONS; appliers know how to project those definitions
* into the database for a specific target type (canvas items, wiki articles, …).
* Each appliesTo value maps to exactly one Applier; appliers self-describe
* which appliesTo values they handle via supports().
*/
interface Applier
{
/**
* @return bool true if this applier handles the given appliesTo value.
*/
public function supports(string $appliesTo): bool;
/**
* Apply the template's content to the target.
*
* @param int $targetId ID of the target object (canvas id, wiki id, …).
* @param ContentTemplate $template The template to project.
* @param array{
* userId?: int,
* mode?: 'add'|'replace',
* } $options Apply-time options.
* userId — author/creator id for inserted rows; falls back to session user.
* mode — 'add' to keep existing content, 'replace' to delete first.
* Defaults to 'add'.
* @return int Number of top-level entities created (canvas items, articles, …).
*/
public function apply(int $targetId, ContentTemplate $template, array $options = []): int;
}

View File

@@ -0,0 +1,51 @@
key: "bug-report"
title: "Bug"
description: "A template for a bug report"
appliesTo: "wiki"
icon: "fa-bug"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.todos"
wiki:
articles:
- title: "Bug"
content: |
<table style="border-collapse: collapse; width: 100.051%;" border="1">
<tbody>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Summary</strong></span></td>
<td style="width: 82.7562%;">summarize the issue you are experiencing</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Environment</strong></span></td>
<td style="width: 82.7562%;">describe the environment under which the problem occured (hosted, production, staging etc)</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Reproduction Steps</strong></span></td>
<td style="width: 82.7562%;">Describe the steps to reproduce the problem<br />
<ol>
<li>Step 1</li>
<li>Step 2</li>
</ol>
</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Expected Outcome</strong></span></td>
<td style="width: 82.7562%;">describe what you expected would happen</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Actual Outcome</strong></span></td>
<td style="width: 82.7562%;">describe what actually happened</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Impact</strong></span></td>
<td style="width: 82.7562%;">describe how impactful this issue is to your workflow (eg can not work at all; delays my work; large inconvenience etc)</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Additional Details</strong></span></td>
<td style="width: 82.7562%;">Anything else you would like to mention</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,32 @@
key: "decision-record"
title: "Decision Record"
description: "Capture a single decision with its context, options weighed, and chosen path."
appliesTo: "wiki"
sector: "process"
icon: "fa-gavel"
author: "Leantime"
version: "1.0.0"
license: "CC0"
wiki:
articles:
- title: "Decision Record"
content: |
<h1>Decision Record</h1>
<p><strong>Status:</strong> Proposed</p>
<p><strong>Date:</strong> </p>
<p><strong>Decision makers:</strong> </p>
<h2>Context</h2>
<p>What's the situation that requires a decision? What problem are we solving?</p>
<h2>Options considered</h2>
<ol>
<li><strong>Option A —</strong> brief description, pros, cons</li>
<li><strong>Option B —</strong> brief description, pros, cons</li>
<li><strong>Option C —</strong> brief description, pros, cons</li>
</ol>
<h2>Decision</h2>
<p>The chosen option and the one-sentence reason.</p>
<h2>Consequences</h2>
<p>What does choosing this mean? What follow-on work does it imply? What did we explicitly NOT choose?</p>
<h2>Revisit</h2>
<p>What would have to change for us to revisit this decision?</p>

View File

@@ -0,0 +1,34 @@
key: "feature-request"
title: "Feature Request"
description: "A template for a feature request"
appliesTo: "wiki"
icon: "fa-lightbulb"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.todos"
wiki:
articles:
- title: "Feature Request"
content: |
<table style="border-collapse: collapse; width: 100.051%;" border="1">
<tbody>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Summary</strong></span></td>
<td style="width: 82.7562%;">summarize the feature you would like</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Target User</strong></span></td>
<td style="width: 82.7562%;">describe who benefits most from this feature</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Desired Timeline</strong></span></td>
<td style="width: 82.7562%;">when would you like to see it implemented</td>
</tr>
<tr>
<td style="width: 17.2438%; background-color: var(--accent1);"><span style="color: #ffffff;"><strong>Additional Details</strong></span></td>
<td style="width: 82.7562%;">any additional details</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,36 @@
key: "meeting-notes"
title: "Meeting Notes"
description: ""
appliesTo: "wiki"
icon: "fa-clipboard"
author: "Leantime"
version: "1.0.0"
license: "CC0"
# Sector doubles as "category" for wiki templates. Resolved via the
# TranslationResolver at consume time so it reads in the user's locale.
sector: "t:templates.documents"
wiki:
articles:
- title: "Meeting Notes"
content: |
<h1>Meeting Notes</h1>
<p><strong>Attendees</strong><br>
<span>Name1, Name2</span></p>
<p><span><br></span></p>
<p><span><strong>Agenda</strong><br></span></p>
<ul>
<li><span>Item 1</span></li>
<li><span>Item 2</span></li>
</ul>
<p><span><br></span></p>
<p><span><strong>Notes</strong><br></span></p>
<ul>
<li><span>Notes here</span></li></ul>
<p><span><br></span></p>
<p><span><strong>Action Items</strong><br></span></p>
<ul data-type="taskList">
<li data-type="taskItem" data-checked="false"><span>Action Item 1</span></li>
<li data-type="taskItem" data-checked="false"><span>Action Item 1</span><span><br></span>
</li>
</ul>

View File

@@ -0,0 +1,152 @@
key: "prd"
title: "t:templates.prd.title"
description: "t:templates.prd.description"
appliesTo: "wiki"
icon: "fa-file-lines"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.documents"
wiki:
articles:
- title: "Product Requirements Document"
# Inline {{ t:KEY }} substitutions handled by TranslationResolver at
# consume time. Author / Date placeholders dropped — the hardcoded
# version baked $author/$today into the HTML, but for a registry-
# served template those need to be editable affordances, not baked.
content: |
<h1><strong>{{ t:templates.prd.title_for_prd }}<br /></strong></h1>
<p>{{ t:templates.author }} <br />
{{ t:templates.dates }} <br />
{{ t:templates.status }} <span class="label label-default">{{ t:templates.status.draft }}</span><br />
<table style="border-collapse: collapse; width: 100%;" border="1">
<thead>
<tr>
<td style="width: 23.3025%;">{{ t:templates.prd.responsible }}</td>
<td style="width: 23.3025%;">{{ t:templates.prd.approve }}</td>
<td style="width: 23.3025%;">{{ t:templates.prd.consulted }}</td>
<td style="width: 23.3025%;">{{ t:templates.prd.informed }}</td>
</tr>
</thead>
<tbody>
<tr>
<td style="width: 23.3025%;">&nbsp;</td>
<td style="width: 23.3025%;">&nbsp;</td>
<td style="width: 23.3025%;">&nbsp;</td>
<td style="width: 23.3025%;">&nbsp;</td>
</tr>
</tbody>
</table>
<h1>{{ t:templates.summary }}</h1>
<h2>{{ t:templates.overview }}</h2>
<p>{{ t:templates.prd.overview_description }}</p>
<h2>{{ t:templates.problem }}</h2>
<p>{{ t:templates.prd.problem_description }}</p>
<h2>Goals (What are we working towards?)</h2>
<p>1. Goal</p>
<p>2. Goal</p>
<p>3. Goal</p>
<h2>Risks (Things that could get in the way or stop progress)</h2>
<p>1. Risk</p>
<p>2. Risk</p>
<p>3. Risk</p>
<h2>Who is the Customer &amp; Description</h2>
<p>Who are the target personas for this product, and which is the key persona?</p>
<table style="border-collapse: collapse; width: 50%;" border="1">
<tbody>
<tr style="height: 17px;">
<td style="width: 11.8519%; height: 17px;">Customer</td>
<td style="width: 88.1481%; height: 17px;">Description (interests, likes, demographics, where to find them)</td>
</tr>
<tr style="height: 17px;">
<td style="width: 11.8519%; height: 17px;">Customer</td>
<td style="width: 88.1481%; height: 17px;">Description (interests, likes, demographics, where to find them)</td>
</tr>
<tr>
<td style="width: 11.8519%;">Customer</td>
<td style="width: 88.1481%;">Description (interests, likes, demographics, where to find them)</td>
</tr>
</tbody>
</table>
<h2>How will the customer use the product?</h2>
<p>Instances where various personas will use the product, in context.</p>
<h3>Use case</h3>
<p>Describe the use case</p>
<h3>Use case</h3>
<p>Describe the use case</p>
<h3>Use case</h3>
<p>Describe the use case</p>
<h1>Product Details</h1>
<p>When you&rsquo;ve locked in your One Pager, build out your PRD. Use the finalized One Pager and the following.</p>
<h2>Features - Must haves</h2>
<p>These are the distinct, prioritized features along with a short explanation of why this feature is important. Briefly outline the scope, the goals, and use case.</p>
<ul>
<li>Feature</li>
<li>Feature</li>
<li>Feature</li>
</ul>
<h2>Features - Nice to Haves</h2>
<ul>
<li>Feature</li>
<li>Feature</li>
<li>Feature</li>
</ul>
<h2>Features - Absolutely Not</h2>
<p>What features have you explicitly decided not to do and why?</p>
<ul>
<li>Feature</li>
<li>Feature</li>
<li>Feature</li>
</ul>
<h2>Design - Any files, images, wireframes or details go here (link to idea board)</h2>
<p>Include any needed early sketches, and throughout the project, link to the actual designs once they&rsquo;re available.</p>
<h2>Technical Considerations - (optional)</h2>
<p>Link to engineering technical approach document.</p>
<h2>Success Metrics</h2>
<p class="c8">What are the&nbsp;<a class="c35" href="https://www.google.com/url?q=https://productschool.com/blog/data-analytics/metrics-product-managers-measure/&amp;sa=D&amp;source=editors&amp;ust=1680296200488974&amp;usg=AOvVaw0DDV-fM6FNiXcUAQjmi42e">success metrics</a> that indicate you&rsquo;re achieving your internal goals for the project? How will you measure success?&nbsp;You can use any goal-setting and tracking system you prefer (OKRs, KPIs, etc).</p>
<p>Note:<span class="c58">&nbsp;Link to Analytics requirements and approach document.</span></p>
<h2>GTM Approach</h2>
<p>What&rsquo;s the product messaging that your &nbsp;marketing department will use to describe this product to existing and new customers? How do you plan to launch this product to the market with marketing and sales teams?</p>
<p>Note:<span class="c20 c63 c56 c58">&nbsp;Link to a larger GTM brief if available.</span></p>
<h2>Open Issues</h2>
<p>What factors do you still need to figure out? What problems may arise and how do you plan on addressing them?</p>
<h2>Q&amp;A</h2>
<p>What are common questions about the product along with the answers you&rsquo;ve decided? This is a good place to note key decisions.</p>
<table style="border-collapse: collapse; width: 100.041%;" border="1">
<thead>
<tr>
<td style="width: 23.3025%;">Question</td>
<td style="width: 23.3025%;">Answer</td>
<td style="width: 23.3025%;">Asked By</td>
<td style="width: 23.3025%;">Answered By</td>
</tr>
</thead>
<tbody>
<tr>
<td style="width: 23.3025%;">&nbsp;</td>
<td style="width: 23.3025%;">&nbsp;</td>
<td style="width: 23.3025%;">&nbsp;</td>
<td style="width: 23.3025%;">&nbsp;</td>
</tr>
</tbody>
</table>
<h2>PRD Checklist</h2>
<p class="c8">Here&rsquo;s a list of topics you must include in your PRD:</p>
<ul data-type="taskList">
<li data-type="taskItem" data-checked="false">Title</li>
<li data-type="taskItem" data-checked="false">Author</li>
<li data-type="taskItem" data-checked="false">Decision Log</li>
<li data-type="taskItem" data-checked="false">Change History</li>
<li data-type="taskItem" data-checked="false">Overview</li>
<li data-type="taskItem" data-checked="false">Messaging</li>
<li data-type="taskItem" data-checked="false">Personas</li>
<li data-type="taskItem" data-checked="false">User Scenarios</li>
<li data-type="taskItem" data-checked="false">User Stories/Features/Requirements</li>
<li data-type="taskItem" data-checked="false">Design</li>
<li data-type="taskItem" data-checked="false">Open Issues</li>
<li data-type="taskItem" data-checked="false">Q&amp;A</li>
</ul>

View File

@@ -0,0 +1,108 @@
key: "project-outline"
title: "Project Outline"
description: ""
appliesTo: "wiki"
icon: "fa-bullseye"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.documents"
wiki:
articles:
- title: "Project Outline"
# Author / Date placeholders are intentionally blank — the user fills
# them in after the template is dropped into the editor. The hardcoded
# version baked $author / $today into the HTML; that doesn't translate
# cleanly to a content-template format (the template is the same
# regardless of who/when applies it), so the editable affordance is the
# right swap.
content: |
<h1><strong>Project Outline<br /></strong></h1>
<p>Author: <br />
Date: <br />
Status: <span class="label label-default">Draft</span><br />
<table style="border-collapse: collapse; width: 100.146%; background-color: #ffffff; height: 182px;" border="1">
<tbody>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">Why we're doing this:</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">&lt; insert project justification &gt;</td>
</tr>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">What are we working on:</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">&lt; insert project description &gt;</td>
</tr>
<tr style="height: 46px;">
<td style="width: 17.3786%; height: 46px; background-color: #3598db;">
<p><strong><span style="color: #ffffff;">Project Objectives:</span></strong></p>
</td>
<td style="height: 46px; width: 82.6214%;" colspan="3">
<ul>
<li>Objective 1</li>
<li>Objective 2</li>
<li>Objective 3</li>
</ul>
</td>
</tr>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">High Level Requirements:</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">
<ul>
<li>Requirement 1</li>
<li>Requirement 2</li>
<li>Requirement 3</li>
</ul>
</td>
</tr>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">In Scope Work</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">
<ul>
<li>Item</li>
<li>Item</li>
<li>Item</li>
</ul>
</td>
</tr>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">Out of Scope Work</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">
<ul>
<li>Item</li>
<li>Item</li>
<li>Item</li>
</ul>
</td>
</tr>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">Risks &amp; Assumptions</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">
<ul>
<li>Risk / Assumption</li>
<li>Risk / Assumption</li>
<li>Risk / Assumption</li>
</ul>
</td>
</tr>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">Timeline / Milestones</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">
<ul>
<li>Milestone — date</li>
<li>Milestone — date</li>
<li>Milestone — date</li>
</ul>
</td>
</tr>
<tr style="height: 17px;">
<td style="width: 17.3786%; height: 17px; background-color: #3598db;"><strong><span style="color: #ffffff;">Stakeholders</span></strong></td>
<td style="height: 17px; width: 82.6214%;" colspan="3">
<ul>
<li>Stakeholder — role</li>
<li>Stakeholder — role</li>
<li>Stakeholder — role</li>
</ul>
</td>
</tr>
</tbody>
</table></p>

View File

@@ -0,0 +1,14 @@
key: "status-gray"
title: "t:templates.titles.gray_status"
description: "t:templates.titles.gray_status_description"
appliesTo: "wiki"
icon: "fa-circle"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.elements"
wiki:
articles:
- title: "Gray Status"
content: '<p><mark data-color="#95A5A6" style="background-color: #95A5A6; color: #ffffff; padding: 0 6px; border-radius: 3px;">Gray</mark></p>'

View File

@@ -0,0 +1,18 @@
key: "status-green"
title: "t:templates.titles.green_status"
description: "t:templates.titles.green_status_description"
appliesTo: "wiki"
icon: "fa-circle-check"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.elements"
wiki:
articles:
- title: "Green Status"
# Renders via TipTap's Highlight extension (multicolor). The pre-
# migration Bootstrap label class (.label-success) never worked in
# the editor because no Span mark is registered; <mark> with an
# inline background is the editor-native equivalent.
content: '<p><mark data-color="#75BB1B" style="background-color: #75BB1B; color: #ffffff; padding: 0 6px; border-radius: 3px;">Green</mark></p>'

View File

@@ -0,0 +1,14 @@
key: "status-red"
title: "t:templates.titles.red_status"
description: "t:templates.titles.red_status_description"
appliesTo: "wiki"
icon: "fa-circle-xmark"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.elements"
wiki:
articles:
- title: "Red Status"
content: '<p><mark data-color="#E74C3C" style="background-color: #E74C3C; color: #ffffff; padding: 0 6px; border-radius: 3px;">Red</mark></p>'

View File

@@ -0,0 +1,14 @@
key: "status-yellow"
title: "t:templates.titles.yellow_status"
description: "t:templates.titles.yellow_status_description"
appliesTo: "wiki"
icon: "fa-circle-exclamation"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.elements"
wiki:
articles:
- title: "Yellow Status"
content: '<p><mark data-color="#F1C40F" style="background-color: #F1C40F; color: #1a1a1a; padding: 0 6px; border-radius: 3px;">Yellow</mark></p>'

View File

@@ -0,0 +1,39 @@
key: "user-story"
title: "User Story"
description: "A template for an agile user story"
appliesTo: "wiki"
icon: "fa-user"
author: "Leantime"
version: "1.0.0"
license: "CC0"
sector: "t:templates.todos"
wiki:
articles:
- title: "User Story"
content: |
<table style="border-collapse: collapse; width: 100.049%;" border="1">
<thead>
<tr>
<td style="width: 33.3333%;">Title:</td>
<td style="width: 33.3333%;">Priority:</td>
<td style="width: 33.3333%;">Estimate:</td>
</tr>
</thead>
<tbody>
<tr>
<td style="width: 100%;" colspan="3">
<h4><strong>User Story</strong></h4>
<p><strong>As a</strong> &lt;insert type of user&gt;</p>
<p><strong>I want to&nbsp;</strong>&lt;perform some task&gt;<br /><br /><strong>so that I can</strong> &lt;achieve some goal&gt;</p>
</td>
</tr>
<tr>
<td colspan="3">
<p><strong>Acceptance Criteria:</strong></p>
<p><strong>Given&nbsp;</strong>&lt;some context&gt;<br /><br /><strong>When&nbsp;</strong>&lt;some action is carried out&gt;</p>
<p><strong>Then&nbsp;</strong>&lt;a set of observable outcomes should occur&gt;&nbsp;</p>
</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,35 @@
key: "weekly-status"
title: "Weekly Status Update"
description: "Concise weekly recap — wins, blockers, next week's focus."
appliesTo: "wiki"
sector: "process"
icon: "fa-calendar-week"
author: "Leantime"
version: "1.0.0"
license: "CC0"
wiki:
articles:
- title: "Weekly Status Update"
content: |
<h1>Weekly Status Update</h1>
<p><strong>Week of:</strong> </p>
<p><strong>From:</strong> </p>
<h2>Wins</h2>
<ul>
<li>What shipped, what's measurably better, what surprised us positively</li>
</ul>
<h2>In flight</h2>
<ul>
<li>What's actively being worked on and the expected landing date</li>
</ul>
<h2>Blockers</h2>
<ul>
<li>What's stuck and what specifically would unstick it</li>
</ul>
<h2>Next week</h2>
<ul>
<li>The 2-3 things that matter most</li>
</ul>
<h2>Risks worth flagging</h2>
<p>Things that aren't blockers yet but are worth telling leadership about now rather than later.</p>

View File

@@ -0,0 +1,72 @@
<?php
namespace Leantime\Domain\ContentTemplates\Models;
/**
* Immutable value object representing a single content template loaded from disk.
*
* Templates describe SEED CONTENT — items to insert into a canvas, or articles
* to create in a wiki — not the schema of the target. (Schemas live in
* Blueprints' TemplateRegistry.)
*
* One of `canvas` or `wiki` payloads is populated, determined by `appliesTo`.
*/
final class ContentTemplate
{
/**
* @param string $key Unique within (appliesTo, key). Slug-like.
* @param string $title Display name in the picker UI.
* @param string $description Short blurb shown alongside the title.
* @param string $appliesTo Target type — canvas type slug ("logicmodel", "leancanvas", ...) or "wiki".
* @param string|null $sector Optional grouping tag (e.g. "education", "health").
* @param string|null $icon Optional Font Awesome class for the selector UI.
* @param string|null $author Attribution. Surfaces in the picker and is preserved on export.
* @param string|null $version Template version. Surfaces in the picker; not used for migrations.
* @param string|null $license License identifier (e.g. "CC0", "MIT").
* @param array<string, mixed> $payload Content payload — shape depends on appliesTo.
*/
public function __construct(
public readonly string $key,
public readonly string $title,
public readonly string $description,
public readonly string $appliesTo,
public readonly ?string $sector = null,
public readonly ?string $icon = null,
public readonly ?string $author = null,
public readonly ?string $version = null,
public readonly ?string $license = null,
public readonly array $payload = [],
) {}
/**
* Construct from the array shape produced by Symfony YAML parsing.
*
* @param array<string, mixed> $data Decoded YAML.
*/
public static function fromArray(array $data): self
{
$appliesTo = (string) ($data['appliesTo'] ?? '');
$payload = (array) ($data[$appliesTo] ?? []);
return new self(
key: (string) ($data['key'] ?? ''),
title: (string) ($data['title'] ?? ''),
description: (string) ($data['description'] ?? ''),
appliesTo: $appliesTo,
sector: isset($data['sector']) ? (string) $data['sector'] : null,
icon: isset($data['icon']) ? (string) $data['icon'] : null,
author: isset($data['author']) ? (string) $data['author'] : null,
version: isset($data['version']) ? (string) $data['version'] : null,
license: isset($data['license']) ? (string) $data['license'] : null,
payload: $payload,
);
}
/**
* True when this template carries a non-empty payload.
*/
public function isUsable(): bool
{
return $this->key !== '' && $this->appliesTo !== '' && $this->payload !== [];
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Leantime\Domain\ContentTemplates\Services\Appliers;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\ContentTemplates\Contracts\Applier;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
/**
* Applier for canvas-typed content templates.
*
* Handles any canvas type whose items live in zp_canvas_items (Logic Model,
* Goal Canvas, Lean Canvas, SWOT, etc.). The appliesTo string is the canvas
* type slug — same slug Blueprints' TemplateRegistry uses. supports() returns
* true for everything except known non-canvas appliesTo values (e.g. "wiki"),
* so this is the catch-all canvas applier.
*
* Expected payload shape:
*
* items:
* - box: "lm_inputs" # required, canvas-type box key
* title: "..." # populates description (the bold line)
* description: "..." # populates conclusion (the supporting prose)
* status: "status_draft" # optional, defaults to ''
* sortindex: 10 # optional, auto-assigned by insertion order if absent
* why_this_matters: "..." # optional, Outcome/Impact items only (authored meaning)
* starting_picture: "..." # optional, Impact items only (the world today)
*
* The unusual title→description / description→conclusion mapping matches the
* Canvas item display convention: description is rendered as the bold card
* title, conclusion as the lighter supporting text. Meaning fields pass
* through with their DB column names — no remapping.
*/
class CanvasItemsApplier implements Applier
{
/** Non-canvas appliesTo values this applier explicitly refuses. */
private const NON_CANVAS = ['wiki'];
/** Resolved on first use so a stubbed DbCore in unit tests doesn't fault. */
private ?ConnectionInterface $db = null;
public function __construct(private DbCore $dbCore) {}
public function supports(string $appliesTo): bool
{
return $appliesTo !== '' && ! in_array($appliesTo, self::NON_CANVAS, true);
}
public function apply(int $targetId, ContentTemplate $template, array $options = []): int
{
if ($targetId <= 0 || ! $template->isUsable()) {
return 0;
}
$items = (array) ($template->payload['items'] ?? []);
if ($items === []) {
return 0;
}
$db = $this->db();
$userId = (int) ($options['userId'] ?? 0);
$mode = $options['mode'] ?? 'add';
if ($mode === 'replace') {
$db->table('zp_canvas_items')
->where('canvasId', $targetId)
->delete();
}
$sortBase = $this->nextSortIndex($targetId);
$created = 0;
// One timestamp for the whole apply — all items from the same template
// application should share created/modified so recent-activity sorts
// don't rank them arbitrarily against each other.
$now = now();
foreach ($items as $offset => $item) {
if (! is_array($item) || empty($item['box'])) {
continue;
}
$row = [
'canvasId' => $targetId,
'box' => (string) $item['box'],
'description' => (string) ($item['title'] ?? ''),
'conclusion' => (string) ($item['description'] ?? ''),
'status' => (string) ($item['status'] ?? ''),
'author' => $userId,
'created' => $now,
// MAX(zp_canvas_items.modified) is the source of truth for a
// board's "last updated" timestamp (see BlueprintsRepository's
// COALESCE(MAX(modified), created) sort). MAX() ignores nulls,
// so leaving modified null makes templated boards appear stale
// in recent-activity lists even though items were just added.
'modified' => $now,
'sortindex' => (int) ($item['sortindex'] ?? ($sortBase + $offset * 10)),
];
// Authored-meaning fields pass through if present in the template.
// YAML keys match DB column names — no remapping like title→description.
if (array_key_exists('why_this_matters', $item)) {
$row['why_this_matters'] = (string) $item['why_this_matters'];
}
if (array_key_exists('starting_picture', $item)) {
$row['starting_picture'] = (string) $item['starting_picture'];
}
$db->table('zp_canvas_items')->insert($row);
$created++;
}
return $created;
}
/**
* Find the next free sortindex on this canvas, leaving room between entries.
* Returns at least 10 so the first item is sortindex >= 10.
*/
private function nextSortIndex(int $canvasId): int
{
$max = (int) $this->db()->table('zp_canvas_items')
->where('canvasId', $canvasId)
->max('sortindex');
return $max > 0 ? $max + 10 : 10;
}
private function db(): ConnectionInterface
{
return $this->db ??= $this->dbCore->getConnection();
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Leantime\Domain\ContentTemplates\Services\Appliers;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\ContentTemplates\Contracts\Applier;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
/**
* Applier for wiki content templates.
*
* Wiki articles share the zp_canvas_items table with all canvas types,
* distinguished by box='article'. Articles can nest via the `parent` column,
* which the template's `children:` arrays drive.
*
* Expected payload shape:
*
* articles:
* - title: "Project Overview"
* content: "<h1>...</h1>" # HTML body
* children:
* - title: "Subtopic A"
* content: "..."
* children: [] # recurses arbitrarily deep
*
* Mode 'replace' wipes all box='article' rows under the wiki before inserting.
* Mode 'add' (default) appends, preserving any existing articles.
*/
class WikiApplier implements Applier
{
/** Resolved on first use so a stubbed DbCore in unit tests doesn't fault. */
private ?ConnectionInterface $db = null;
public function __construct(private DbCore $dbCore) {}
public function supports(string $appliesTo): bool
{
return $appliesTo === 'wiki';
}
public function apply(int $targetId, ContentTemplate $template, array $options = []): int
{
if ($targetId <= 0 || ! $template->isUsable()) {
return 0;
}
$articles = (array) ($template->payload['articles'] ?? []);
if ($articles === []) {
return 0;
}
$userId = (int) ($options['userId'] ?? 0);
$mode = $options['mode'] ?? 'add';
if ($mode === 'replace') {
$this->db()->table('zp_canvas_items')
->where('canvasId', $targetId)
->where('box', 'article')
->delete();
}
return $this->insertArticles($articles, $targetId, $userId, parent: 0);
}
/**
* Insert a list of articles under a given parent, recursing into children.
*
* @param list<array<string, mixed>> $articles
* @param int $parent Parent article id (0 for top-level).
* @return int Total articles created (this level + all descendants).
*/
private function insertArticles(array $articles, int $wikiId, int $userId, int $parent): int
{
$created = 0;
$sortBase = 10;
$db = $this->db();
foreach ($articles as $offset => $article) {
if (! is_array($article)) {
continue;
}
$id = (int) $db->table('zp_canvas_items')->insertGetId([
'canvasId' => $wikiId,
'box' => 'article',
'title' => (string) ($article['title'] ?? ''),
'description' => (string) ($article['content'] ?? ''),
'parent' => $parent,
'author' => $userId,
'created' => now(),
'modified' => now(),
'sortindex' => $sortBase + $offset * 10,
]);
$created++;
$children = (array) ($article['children'] ?? []);
if ($id > 0 && $children !== []) {
$created += $this->insertArticles($children, $wikiId, $userId, $id);
}
}
return $created;
}
private function db(): ConnectionInterface
{
return $this->db ??= $this->dbCore->getConnection();
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace Leantime\Domain\ContentTemplates\Services;
use Leantime\Domain\ContentTemplates\Contracts\Applier;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
use Symfony\Component\Yaml\Yaml;
/**
* Loads and indexes content templates from one or more library directories.
*
* Library layout convention (per directory root):
*
* <root>/<appliesTo>/<key>.yaml
*
* e.g. Library/logicmodel/education-k12.yaml
*
* Plugins can register additional roots via registerLibraryRoot(); the registry
* scans the union of all registered roots on first access and caches the result
* for the request.
*
* Appliers are registered separately (one per appliesTo). Resolving an applier
* for a given template tells callers which service to dispatch the apply to.
*/
class ContentTemplateRegistry
{
/** @var list<string> Absolute paths to library directories. */
private array $libraryRoots = [];
/** @var array<string, array<string, ContentTemplate>>|null Cache: appliesTo → key → template. */
private ?array $templates = null;
/** @var array<string, Applier> appliesTo → applier instance. */
private array $appliers = [];
public function __construct()
{
$coreLibrary = APP_ROOT.'/app/Domain/ContentTemplates/Library';
if (is_dir($coreLibrary)) {
$this->libraryRoots[] = $coreLibrary;
}
}
/**
* Register an additional library root. Idempotent — duplicate paths are ignored.
*
* @param string $absolutePath Directory containing per-appliesTo subdirectories of .yaml templates.
*/
public function registerLibraryRoot(string $absolutePath): void
{
$absolutePath = rtrim($absolutePath, DIRECTORY_SEPARATOR);
if ($absolutePath === '' || in_array($absolutePath, $this->libraryRoots, true)) {
return;
}
$this->libraryRoots[] = $absolutePath;
$this->templates = null; // invalidate cache
}
/**
* Register an applier for an appliesTo value. Overwrites if already registered.
*/
public function registerApplier(string $appliesTo, Applier $applier): void
{
$this->appliers[$appliesTo] = $applier;
}
/**
* Resolve an applier for an appliesTo value.
*
* First tries an explicit binding. If none, falls back to scanning the
* registered appliers for one whose supports() returns true for the
* given appliesTo — letting the catch-all CanvasItemsApplier handle
* any non-wiki canvas type that hasn't been explicitly bound.
*/
public function applierFor(string $appliesTo): ?Applier
{
if (isset($this->appliers[$appliesTo])) {
return $this->appliers[$appliesTo];
}
foreach ($this->appliers as $candidate) {
if ($candidate->supports($appliesTo)) {
return $candidate;
}
}
return null;
}
/**
* Get a single template by (appliesTo, key).
*/
public function get(string $appliesTo, string $key): ?ContentTemplate
{
$all = $this->loadAll();
return $all[$appliesTo][$key] ?? null;
}
/**
* @return array<string, ContentTemplate> All templates for the given appliesTo, keyed by template key.
*/
public function forAppliesTo(string $appliesTo): array
{
$all = $this->loadAll();
return $all[$appliesTo] ?? [];
}
/**
* @return array<string, array<string, ContentTemplate>> All templates across all roots, by appliesTo → key.
*/
public function all(): array
{
return $this->loadAll();
}
/**
* Scan all registered library roots, parse YAML, build the index.
*
* Later roots override earlier ones on (appliesTo, key) collision, so a
* plugin can override a core template with the same key if needed.
*
* @return array<string, array<string, ContentTemplate>>
*/
private function loadAll(): array
{
if ($this->templates !== null) {
return $this->templates;
}
$index = [];
foreach ($this->libraryRoots as $root) {
if (! is_dir($root)) {
continue;
}
// Each direct subdirectory of a root is an appliesTo bucket.
foreach ((array) glob($root.'/*', GLOB_ONLYDIR) as $appliesDir) {
$appliesTo = basename((string) $appliesDir);
foreach ((array) glob($appliesDir.'/*.yaml') as $yamlFile) {
$template = $this->loadYaml((string) $yamlFile);
if ($template === null || ! $template->isUsable()) {
continue;
}
// Force appliesTo to match the directory the file lives in,
// so a misnamed YAML can't claim a different bucket.
if ($template->appliesTo !== $appliesTo) {
$template = new ContentTemplate(
key: $template->key,
title: $template->title,
description: $template->description,
appliesTo: $appliesTo,
sector: $template->sector,
icon: $template->icon,
author: $template->author,
version: $template->version,
license: $template->license,
payload: $template->payload,
);
}
$index[$appliesTo][$template->key] = $template;
}
}
}
return $this->templates = $index;
}
/**
* Parse a single YAML file into a ContentTemplate. Returns null on failure.
*/
private function loadYaml(string $path): ?ContentTemplate
{
try {
$data = Yaml::parseFile($path);
} catch (\Throwable $e) {
return null;
}
if (! is_array($data)) {
return null;
}
return ContentTemplate::fromArray($data);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Leantime\Domain\ContentTemplates\Support;
/**
* Resolves translation references inside content-template string fields.
*
* Templates carry raw strings on disk. Consumers that want translation
* call resolve() on each user-facing string. Two conventions are
* supported, matching the way the hardcoded wiki templates used __():
*
* "t:templates.prd.title"
* The whole string is a translation key. Resolved to the locale's
* translation.
*
* "{{ t:templates.prd.author }} Gloria Folaron"
* Substring substitution. Every occurrence of `{{ t:KEY }}` is
* replaced with the locale's translation, leaving surrounding text
* intact. Whitespace inside the braces is tolerated.
*
* Resolution happens at consumer-call time so the current request's
* locale is honored — caching the parsed template per-locale would
* be the alternative but adds complexity without a real win for
* content templates (cold-path, single resolve per page render).
*
* Implemented as a static utility because there's no per-instance
* state. Callers don't need to inject it.
*/
final class TranslationResolver
{
/**
* Resolve translation references in a single string.
*/
public static function resolve(string $input): string
{
if ($input === '') {
return $input;
}
if (str_starts_with($input, 't:')) {
return (string) __(substr($input, 2));
}
return (string) preg_replace_callback(
'/\{\{\s*t:([\w.-]+)\s*\}\}/',
static fn (array $m): string => (string) __($m[1]),
$input
);
}
/**
* Walk an array, recursively, and resolve translation references in
* every string value. Non-string values are passed through untouched.
*
* @param array<mixed, mixed> $data
* @return array<mixed, mixed>
*/
public static function resolveArray(array $data): array
{
foreach ($data as $key => $value) {
if (is_string($value)) {
$data[$key] = self::resolve($value);
} elseif (is_array($value)) {
$data[$key] = self::resolveArray($value);
}
}
return $data;
}
}