Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8475f9604c | |||
| 226ab8ea84 | |||
| d48ee8eae5 | |||
| 1dadc31884 | |||
| cdd7ca7626 | |||
| e1bf9ecb22 |
@@ -109,7 +109,7 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
|
|||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
- 60 tools MCP exposant projets, tâches, métadonnées, time tracking, récurrences, documents et absences
|
||||||
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
|
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
|
||||||
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
|
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
|
||||||
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
|
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|
||||||
|
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||||
|
arguments:
|
||||||
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|
||||||
App\Controller\UserAvatarController:
|
App\Controller\UserAvatarController:
|
||||||
arguments:
|
arguments:
|
||||||
$avatarUploadDir: '%avatar_upload_dir%'
|
$avatarUploadDir: '%avatar_upload_dir%'
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.21'
|
app.version: '0.4.24'
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
import { formatFileSize } from '~/utils/format'
|
import { formatFileSize } from '~/utils/format'
|
||||||
|
import { copyToClipboard } from '~/utils/clipboard'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
document: TaskDocument | null
|
document: TaskDocument | null
|
||||||
@@ -159,13 +160,10 @@ const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
|||||||
const isText = computed(() => isTextDocument(props.document))
|
const isText = computed(() => isTextDocument(props.document))
|
||||||
|
|
||||||
async function copyContent() {
|
async function copyContent() {
|
||||||
try {
|
if (await copyToClipboard(textContent.value)) {
|
||||||
await navigator.clipboard.writeText(textContent.value)
|
|
||||||
copied.value = true
|
copied.value = true
|
||||||
useToast().success(t('taskDocuments.copied'))
|
useToast().success(t('taskDocuments.copied'))
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
} catch {
|
|
||||||
// Clipboard unavailable
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,7 @@
|
|||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||||
import { useGiteaService } from '~/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
|
import { copyToClipboard } from '~/utils/clipboard'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -374,7 +375,7 @@ async function handleCreate() {
|
|||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
try {
|
try {
|
||||||
const result = await getBranchName(props.task.id, branchForm.type)
|
const result = await getBranchName(props.task.id, branchForm.type)
|
||||||
await navigator.clipboard.writeText(result.name)
|
await copyToClipboard(result.name)
|
||||||
const { success } = useToast()
|
const { success } = useToast()
|
||||||
success(t('gitea.branch.copied'))
|
success(t('gitea.branch.copied'))
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
v-for="cat in CATEGORIES"
|
v-for="cat in CATEGORIES"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50 transition"
|
||||||
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||||
@dragover.prevent="dragOverCategory = cat"
|
@dragover.prevent="dragOverCategory = cat"
|
||||||
@dragleave="dragOverCategory = null"
|
@dragleave="dragOverCategory = null"
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAvatarService } from '~/composables/useAvatarService'
|
import { useAvatarService } from '~/composables/useAvatarService'
|
||||||
import { useApiTokenService } from '~/services/api-token'
|
import { useApiTokenService } from '~/services/api-token'
|
||||||
|
import { copyToClipboard } from '~/utils/clipboard'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -181,10 +182,9 @@ async function onRemove() {
|
|||||||
|
|
||||||
async function onCopy() {
|
async function onCopy() {
|
||||||
if (!auth.user?.apiToken) return
|
if (!auth.user?.apiToken) return
|
||||||
try {
|
if (await copyToClipboard(auth.user.apiToken)) {
|
||||||
await navigator.clipboard.writeText(auth.user.apiToken)
|
|
||||||
toast.success({ message: t('profile.apiToken.copied') })
|
toast.success({ message: t('profile.apiToken.copied') })
|
||||||
} catch {
|
} else {
|
||||||
toast.error({ message: t('profile.apiToken.copyFailed') })
|
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="status in statuses"
|
v-for="status in statuses"
|
||||||
:key="status.id"
|
:key="status.id"
|
||||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@dragenter.prevent="onDragEnter(status.id)"
|
@dragenter.prevent="onDragEnter(status.id)"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Copy text to the clipboard with a fallback for non-secure contexts.
|
||||||
|
*
|
||||||
|
* `navigator.clipboard` is only available in secure contexts (HTTPS or
|
||||||
|
* localhost). On a plain HTTP origin (e.g. an internal/prod server without
|
||||||
|
* TLS) the API is missing, so we fall back to the legacy
|
||||||
|
* `document.execCommand('copy')` using a temporary off-screen textarea.
|
||||||
|
*
|
||||||
|
* @returns `true` if the copy succeeded, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
// Preferred path: available in secure contexts (HTTPS / localhost).
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// Fall through to the legacy fallback below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: works on plain HTTP origins.
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
// Keep it out of view and prevent layout shift / scrolling.
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.top = '-9999px'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, text.length)
|
||||||
|
const ok = document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
return ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Task;
|
||||||
|
|
||||||
|
use App\Entity\TaskDocument;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-task-document', description: 'Delete a document attached to a task, permanently. The underlying file is also removed from disk.')]
|
||||||
|
class DeleteTaskDocumentTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $id ID of the task document to delete
|
||||||
|
*/
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = $this->entityManager->find(TaskDocument::class, $id);
|
||||||
|
if (null === $document) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskId = $document->getTask()?->getId();
|
||||||
|
$originalName = $document->getOriginalName();
|
||||||
|
|
||||||
|
$this->entityManager->remove($document);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => sprintf('Document "%s" (ID %d) deleted.', $originalName, $id),
|
||||||
|
'id' => $id,
|
||||||
|
'taskId' => $taskId,
|
||||||
|
'originalName' => $originalName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Task;
|
||||||
|
|
||||||
|
use App\Entity\TaskDocument;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
use function strlen;
|
||||||
|
|
||||||
|
#[McpTool(name: 'update-task-document', description: 'Update a document attached to a task: replace its text content and/or rename it. Pass the new raw content (verbatim UTF-8) and/or a new fileName. The MIME type is re-inferred from the fileName extension. At least one of content or fileName must be provided.')]
|
||||||
|
class UpdateTaskDocumentTool
|
||||||
|
{
|
||||||
|
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
|
||||||
|
|
||||||
|
private const EXTENSION_TO_MIME = [
|
||||||
|
'md' => 'text/markdown',
|
||||||
|
'markdown' => 'text/markdown',
|
||||||
|
'txt' => 'text/plain',
|
||||||
|
'csv' => 'text/csv',
|
||||||
|
'json' => 'application/json',
|
||||||
|
'xml' => 'text/xml',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly string $uploadDir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $id ID of the task document to update
|
||||||
|
* @param null|string $content New raw text content of the document (e.g. Markdown). Omit to keep the current content.
|
||||||
|
* @param null|string $fileName New display name of the document, including extension. Omit to keep the current name.
|
||||||
|
*/
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $content = null,
|
||||||
|
?string $fileName = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $content && null === $fileName) {
|
||||||
|
throw new InvalidArgumentException('At least one of content or fileName must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = $this->entityManager->find(TaskDocument::class, $id);
|
||||||
|
if (null === $document) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename: update the display name and re-infer the MIME type from its extension.
|
||||||
|
if (null !== $fileName) {
|
||||||
|
$originalName = trim($fileName);
|
||||||
|
if ('' === $originalName) {
|
||||||
|
throw new InvalidArgumentException('fileName cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||||
|
if ('' === $extension) {
|
||||||
|
$originalName .= '.md';
|
||||||
|
$extension = 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->setOriginalName($originalName);
|
||||||
|
$document->setMimeType(self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace content: overwrite the stored file in place and refresh its size.
|
||||||
|
if (null !== $content) {
|
||||||
|
if ('' === $content) {
|
||||||
|
throw new InvalidArgumentException('Document content cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = strlen($content);
|
||||||
|
if ($size > self::MAX_CONTENT_SIZE) {
|
||||||
|
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||||
|
if (false === file_put_contents($filePath, $content)) {
|
||||||
|
throw new InvalidArgumentException('Failed to write document to disk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->setSize($size);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'id' => $document->getId(),
|
||||||
|
'taskId' => $document->getTask()?->getId(),
|
||||||
|
'originalName' => $document->getOriginalName(),
|
||||||
|
'mimeType' => $document->getMimeType(),
|
||||||
|
'size' => $document->getSize(),
|
||||||
|
'createdAt' => $document->getCreatedAt()?->format('c'),
|
||||||
|
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user