OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user