OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Domain/ContentTemplates/Contracts/Applier.php
Normal file
37
app/Domain/ContentTemplates/Contracts/Applier.php
Normal 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;
|
||||
}
|
||||
0
app/Domain/ContentTemplates/Library/goal/.gitkeep
Normal file
0
app/Domain/ContentTemplates/Library/goal/.gitkeep
Normal file
0
app/Domain/ContentTemplates/Library/lean/.gitkeep
Normal file
0
app/Domain/ContentTemplates/Library/lean/.gitkeep
Normal file
0
app/Domain/ContentTemplates/Library/wiki/.gitkeep
Normal file
0
app/Domain/ContentTemplates/Library/wiki/.gitkeep
Normal file
51
app/Domain/ContentTemplates/Library/wiki/bug-report.yaml
Normal file
51
app/Domain/ContentTemplates/Library/wiki/bug-report.yaml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
36
app/Domain/ContentTemplates/Library/wiki/meeting-notes.yaml
Normal file
36
app/Domain/ContentTemplates/Library/wiki/meeting-notes.yaml
Normal 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>
|
||||
152
app/Domain/ContentTemplates/Library/wiki/prd.yaml
Normal file
152
app/Domain/ContentTemplates/Library/wiki/prd.yaml
Normal 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%;"> </td>
|
||||
<td style="width: 23.3025%;"> </td>
|
||||
<td style="width: 23.3025%;"> </td>
|
||||
<td style="width: 23.3025%;"> </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 & 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’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’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 <a class="c35" href="https://www.google.com/url?q=https://productschool.com/blog/data-analytics/metrics-product-managers-measure/&sa=D&source=editors&ust=1680296200488974&usg=AOvVaw0DDV-fM6FNiXcUAQjmi42e">success metrics</a> that indicate you’re achieving your internal goals for the project? How will you measure success? You can use any goal-setting and tracking system you prefer (OKRs, KPIs, etc).</p>
|
||||
<p>Note:<span class="c58"> Link to Analytics requirements and approach document.</span></p>
|
||||
<h2>GTM Approach</h2>
|
||||
<p>What’s the product messaging that your 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"> 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&A</h2>
|
||||
<p>What are common questions about the product along with the answers you’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%;"> </td>
|
||||
<td style="width: 23.3025%;"> </td>
|
||||
<td style="width: 23.3025%;"> </td>
|
||||
<td style="width: 23.3025%;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>PRD Checklist</h2>
|
||||
<p class="c8">Here’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&A</li>
|
||||
</ul>
|
||||
108
app/Domain/ContentTemplates/Library/wiki/project-outline.yaml
Normal file
108
app/Domain/ContentTemplates/Library/wiki/project-outline.yaml
Normal 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">< insert project justification ></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">< insert project description ></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 & 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>
|
||||
14
app/Domain/ContentTemplates/Library/wiki/status-gray.yaml
Normal file
14
app/Domain/ContentTemplates/Library/wiki/status-gray.yaml
Normal 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>'
|
||||
18
app/Domain/ContentTemplates/Library/wiki/status-green.yaml
Normal file
18
app/Domain/ContentTemplates/Library/wiki/status-green.yaml
Normal 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>'
|
||||
14
app/Domain/ContentTemplates/Library/wiki/status-red.yaml
Normal file
14
app/Domain/ContentTemplates/Library/wiki/status-red.yaml
Normal 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>'
|
||||
14
app/Domain/ContentTemplates/Library/wiki/status-yellow.yaml
Normal file
14
app/Domain/ContentTemplates/Library/wiki/status-yellow.yaml
Normal 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>'
|
||||
39
app/Domain/ContentTemplates/Library/wiki/user-story.yaml
Normal file
39
app/Domain/ContentTemplates/Library/wiki/user-story.yaml
Normal 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> <insert type of user></p>
|
||||
<p><strong>I want to </strong><perform some task><br /><br /><strong>so that I can</strong> <achieve some goal></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<p><strong>Acceptance Criteria:</strong></p>
|
||||
<p><strong>Given </strong><some context><br /><br /><strong>When </strong><some action is carried out></p>
|
||||
<p><strong>Then </strong><a set of observable outcomes should occur> </p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
35
app/Domain/ContentTemplates/Library/wiki/weekly-status.yaml
Normal file
35
app/Domain/ContentTemplates/Library/wiki/weekly-status.yaml
Normal 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>
|
||||
72
app/Domain/ContentTemplates/Models/ContentTemplate.php
Normal file
72
app/Domain/ContentTemplates/Models/ContentTemplate.php
Normal 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 !== [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
109
app/Domain/ContentTemplates/Services/Appliers/WikiApplier.php
Normal file
109
app/Domain/ContentTemplates/Services/Appliers/WikiApplier.php
Normal 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();
|
||||
}
|
||||
}
|
||||
187
app/Domain/ContentTemplates/Services/ContentTemplateRegistry.php
Normal file
187
app/Domain/ContentTemplates/Services/ContentTemplateRegistry.php
Normal 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);
|
||||
}
|
||||
}
|
||||
70
app/Domain/ContentTemplates/Support/TranslationResolver.php
Normal file
70
app/Domain/ContentTemplates/Support/TranslationResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user