From 83b1cb397d9374ae8b763579dd69b411cd5462f8 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Thu, 26 Mar 2026 19:36:25 -0300 Subject: [PATCH] adding gitea service --- .gitignore | 1 + .../Controllers/RepositoryController.cs | 40 ++++ Mindforge.API/Models/FileTreeNode.cs | 10 + Mindforge.API/Program.cs | 17 ++ Mindforge.API/Services/GiteaService.cs | 165 ++++++++++++++ .../Services/Interfaces/IGiteaService.cs | 11 + Mindforge.API/appsettings.json | 6 +- Mindforge.API/deploy/mindforge-api.yaml | 10 + .../src/components/FileTreeComponent.css | 89 ++++++++ .../src/components/FileTreeComponent.tsx | 82 +++++++ .../src/components/FlashcardComponent.tsx | 92 +++----- Mindforge.Web/src/components/Header.css | 25 +++ Mindforge.Web/src/components/Header.tsx | 17 +- .../src/components/VerificadorComponent.css | 18 ++ .../src/components/VerificadorComponent.tsx | 205 +++++++++--------- .../src/services/MindforgeApiService.ts | 36 ++- 16 files changed, 658 insertions(+), 166 deletions(-) create mode 100644 Mindforge.API/Controllers/RepositoryController.cs create mode 100644 Mindforge.API/Models/FileTreeNode.cs create mode 100644 Mindforge.API/Services/GiteaService.cs create mode 100644 Mindforge.API/Services/Interfaces/IGiteaService.cs create mode 100644 Mindforge.Web/src/components/FileTreeComponent.css create mode 100644 Mindforge.Web/src/components/FileTreeComponent.tsx diff --git a/.gitignore b/.gitignore index 4c5f206..4ef74cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .claude/ +_bmad* \ No newline at end of file diff --git a/Mindforge.API/Controllers/RepositoryController.cs b/Mindforge.API/Controllers/RepositoryController.cs new file mode 100644 index 0000000..6919ed5 --- /dev/null +++ b/Mindforge.API/Controllers/RepositoryController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using Mindforge.API.Services.Interfaces; + +namespace Mindforge.API.Controllers +{ + [ApiController] + [Route("api/v1/repository")] + public class RepositoryController : ControllerBase + { + private readonly IGiteaService _giteaService; + + public RepositoryController(IGiteaService giteaService) + { + _giteaService = giteaService; + } + + [HttpGet("info")] + public IActionResult GetInfo() + { + return Ok(new { name = _giteaService.GetRepositoryName() }); + } + + [HttpGet("tree")] + public async Task GetTree() + { + var tree = await _giteaService.GetFileTreeAsync(); + return Ok(tree); + } + + [HttpGet("file")] + public async Task GetFile([FromQuery] string path) + { + if (string.IsNullOrWhiteSpace(path)) + return BadRequest(new { error = "path query parameter is required." }); + + var content = await _giteaService.GetFileContentAsync(path); + return Ok(new { path, content }); + } + } +} diff --git a/Mindforge.API/Models/FileTreeNode.cs b/Mindforge.API/Models/FileTreeNode.cs new file mode 100644 index 0000000..18025dc --- /dev/null +++ b/Mindforge.API/Models/FileTreeNode.cs @@ -0,0 +1,10 @@ +namespace Mindforge.API.Models +{ + public class FileTreeNode + { + public string Name { get; set; } = ""; + public string Path { get; set; } = ""; + public string Type { get; set; } = ""; // "file" | "folder" + public List? Children { get; set; } + } +} diff --git a/Mindforge.API/Program.cs b/Mindforge.API/Program.cs index 0ea2199..1c0966f 100644 --- a/Mindforge.API/Program.cs +++ b/Mindforge.API/Program.cs @@ -7,6 +7,9 @@ using Mindforge.API.Services.Interfaces; var builder = WebApplication.CreateBuilder(args); +// Ensure environment variables are loaded into IConfiguration +builder.Configuration.AddEnvironmentVariables(); + // Add services to the container. builder.Services.AddControllers(); builder.Logging.AddConsole(); @@ -37,6 +40,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -68,4 +72,17 @@ if (string.IsNullOrEmpty(geminiKey)) app.Logger.LogWarning("GEMINI_API_KEY not found in configuration."); } +var giteaRepoUrl = builder.Configuration["GITEA_REPO_URL"]; +var giteaAccessToken = builder.Configuration["GITEA_ACCESS_TOKEN"]; + +if (string.IsNullOrEmpty(giteaRepoUrl)) +{ + app.Logger.LogWarning("GITEA_REPO_URL not found in configuration. Repository features will not work."); +} + +if (string.IsNullOrEmpty(giteaAccessToken)) +{ + app.Logger.LogWarning("GITEA_ACCESS_TOKEN not found in configuration. Repository features will not work."); +} + app.Run(); diff --git a/Mindforge.API/Services/GiteaService.cs b/Mindforge.API/Services/GiteaService.cs new file mode 100644 index 0000000..0388e1a --- /dev/null +++ b/Mindforge.API/Services/GiteaService.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Mindforge.API.Exceptions; +using Mindforge.API.Models; +using Mindforge.API.Services.Interfaces; + +namespace Mindforge.API.Services +{ + public class GiteaService : IGiteaService + { + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string _owner; + private readonly string _repo; + private readonly string _token; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public GiteaService(HttpClient httpClient, IConfiguration configuration) + { + _httpClient = httpClient; + + var repoUrl = configuration["GITEA_REPO_URL"]; + if (string.IsNullOrEmpty(repoUrl)) + throw new InvalidOperationException("GITEA_REPO_URL is not set in configuration."); + + _token = configuration["GITEA_ACCESS_TOKEN"] + ?? throw new InvalidOperationException("GITEA_ACCESS_TOKEN is not set in configuration."); + + // Parse: https://host/owner/repo or https://host/owner/repo.git + var normalizedUrl = repoUrl.TrimEnd('/').TrimEnd(".git".ToCharArray()); + var uri = new Uri(normalizedUrl); + _baseUrl = $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}"; + var parts = uri.AbsolutePath.Trim('/').Split('/'); + _owner = parts[0]; + _repo = parts[1]; + } + + public string GetRepositoryName() => _repo; + + public async Task> GetFileTreeAsync() + { + // Get the master branch to obtain the latest commit SHA + var branchJson = await GetApiAsync($"/api/v1/repos/{_owner}/{_repo}/branches/master"); + var branch = JsonSerializer.Deserialize(branchJson, JsonOptions) + ?? throw new InvalidOperationException("Failed to parse branch response from Gitea."); + + var treeSha = branch.Commit.Id; + + // Fetch the full recursive tree + var treeJson = await GetApiAsync($"/api/v1/repos/{_owner}/{_repo}/git/trees/{treeSha}?recursive=true"); + var treeResponse = JsonSerializer.Deserialize(treeJson, JsonOptions) + ?? throw new InvalidOperationException("Failed to parse tree response from Gitea."); + + return BuildTree(treeResponse.Tree); + } + + public async Task GetFileContentAsync(string path) + { + var request = new HttpRequestMessage(HttpMethod.Get, + $"{_baseUrl}/api/v1/repos/{_owner}/{_repo}/raw/{path}?ref=master"); + request.Headers.Add("Authorization", $"token {_token}"); + + var response = await _httpClient.SendAsync(request); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + throw new UserException($"File not found in repository: {path}"); + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + private async Task GetApiAsync(string endpoint) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}{endpoint}"); + request.Headers.Add("Authorization", $"token {_token}"); + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + private static List BuildTree(List items) + { + var root = new List(); + var folderMap = new Dictionary(); + + // Only include .md files and their parent folders + var mdFiles = items.Where(i => i.Type == "blob" && i.Path.EndsWith(".md")).ToList(); + var neededFolders = new HashSet(); + foreach (var file in mdFiles) + { + var dir = System.IO.Path.GetDirectoryName(file.Path)?.Replace('\\', '/'); + while (!string.IsNullOrEmpty(dir)) + { + neededFolders.Add(dir); + dir = System.IO.Path.GetDirectoryName(dir)?.Replace('\\', '/'); + } + } + + // Create folder nodes + foreach (var item in items.Where(i => i.Type == "tree" && neededFolders.Contains(i.Path)).OrderBy(i => i.Path)) + { + var node = new FileTreeNode + { + Name = System.IO.Path.GetFileName(item.Path), + Path = item.Path, + Type = "folder", + Children = [] + }; + folderMap[item.Path] = node; + } + + // Place file and folder nodes into parent + var allNodes = mdFiles.Select(i => new FileTreeNode + { + Name = System.IO.Path.GetFileName(i.Path), + Path = i.Path, + Type = "file" + }).Concat(folderMap.Values).OrderBy(n => n.Path); + + foreach (var node in allNodes) + { + var parentPath = System.IO.Path.GetDirectoryName(node.Path)?.Replace('\\', '/'); + if (string.IsNullOrEmpty(parentPath)) + root.Add(node); + else if (folderMap.TryGetValue(parentPath, out var parent)) + parent.Children!.Add(node); + } + + return root; + } + + // ---- Gitea API DTOs ---- + + private class GiteaBranch + { + [JsonPropertyName("commit")] + public GiteaCommit Commit { get; set; } = new(); + } + + private class GiteaCommit + { + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + } + + private class GiteaTreeResponse + { + [JsonPropertyName("tree")] + public List Tree { get; set; } = []; + } + + private class GiteaTreeItem + { + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("type")] + public string Type { get; set; } = ""; // "blob" | "tree" + } + } +} diff --git a/Mindforge.API/Services/Interfaces/IGiteaService.cs b/Mindforge.API/Services/Interfaces/IGiteaService.cs new file mode 100644 index 0000000..e1b7ca7 --- /dev/null +++ b/Mindforge.API/Services/Interfaces/IGiteaService.cs @@ -0,0 +1,11 @@ +using Mindforge.API.Models; + +namespace Mindforge.API.Services.Interfaces +{ + public interface IGiteaService + { + Task> GetFileTreeAsync(); + Task GetFileContentAsync(string path); + string GetRepositoryName(); + } +} diff --git a/Mindforge.API/appsettings.json b/Mindforge.API/appsettings.json index 10f68b8..be62f02 100644 --- a/Mindforge.API/appsettings.json +++ b/Mindforge.API/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OPENAI_API_KEY": "", + "GEMINI_API_KEY": "", + "GITEA_REPO_URL": "", + "GITEA_ACCESS_TOKEN": "" } diff --git a/Mindforge.API/deploy/mindforge-api.yaml b/Mindforge.API/deploy/mindforge-api.yaml index 34facfc..1642a15 100644 --- a/Mindforge.API/deploy/mindforge-api.yaml +++ b/Mindforge.API/deploy/mindforge-api.yaml @@ -30,6 +30,16 @@ spec: secretKeyRef: name: mindforge-secrets key: GEMINI_API_KEY + - name: GITEA_REPO_URL + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: GITEA_REPO_URL + - name: GITEA_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: GITEA_ACCESS_TOKEN resources: requests: memory: "128Mi" diff --git a/Mindforge.Web/src/components/FileTreeComponent.css b/Mindforge.Web/src/components/FileTreeComponent.css new file mode 100644 index 0000000..98a848c --- /dev/null +++ b/Mindforge.Web/src/components/FileTreeComponent.css @@ -0,0 +1,89 @@ +.file-tree { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.75rem; + max-height: 380px; + overflow-y: auto; + font-size: 0.9rem; +} + +.tree-folder { + margin-bottom: 2px; +} + +.tree-folder-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + user-select: none; + color: rgba(255, 255, 255, 0.85); + font-weight: 600; +} + +.tree-folder-header:hover { + background: rgba(255, 255, 255, 0.07); +} + +.tree-folder-arrow { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + width: 12px; +} + +.tree-folder-name { + color: rgba(255, 255, 255, 0.85); +} + +.tree-folder-children { + padding-left: 18px; + border-left: 1px solid rgba(255, 255, 255, 0.08); + margin-left: 6px; +} + +.tree-file { + margin-bottom: 1px; +} + +.tree-file-label { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + color: rgba(255, 255, 255, 0.7); +} + +.tree-file-label:hover { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.9); +} + +.tree-file-label input[type="checkbox"] { + accent-color: #7c6fcd; + width: 14px; + height: 14px; + cursor: pointer; + flex-shrink: 0; +} + +.tree-file-name { + font-size: 0.875rem; +} + +.tree-loading, +.tree-error, +.tree-empty { + padding: 1rem; + color: rgba(255, 255, 255, 0.5); + font-size: 0.9rem; + text-align: center; +} + +.tree-error { + color: #ff7b72; +} diff --git a/Mindforge.Web/src/components/FileTreeComponent.tsx b/Mindforge.Web/src/components/FileTreeComponent.tsx new file mode 100644 index 0000000..9cdad5b --- /dev/null +++ b/Mindforge.Web/src/components/FileTreeComponent.tsx @@ -0,0 +1,82 @@ +import { useState, useEffect } from 'preact/hooks'; +import { MindforgeApiService, type FileTreeNode } from '../services/MindforgeApiService'; +import './FileTreeComponent.css'; + +interface FileTreeComponentProps { + selectedPaths: string[]; + onSelectionChange: (paths: string[]) => void; +} + +interface FolderNodeProps { + node: FileTreeNode; + selectedPaths: string[]; + onToggle: (path: string) => void; +} + +function FolderNode({ node, selectedPaths, onToggle }: FolderNodeProps) { + const [expanded, setExpanded] = useState(true); + + if (node.type === 'file') { + return ( +
+ +
+ ); + } + + return ( +
+
setExpanded(e => !e)}> + {expanded ? '▾' : '▸'} + {node.name} +
+ {expanded && node.children && ( +
+ {node.children.map(child => ( + + ))} +
+ )} +
+ ); +} + +export function FileTreeComponent({ selectedPaths, onSelectionChange }: FileTreeComponentProps) { + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + MindforgeApiService.getRepositoryTree() + .then(setTree) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, []); + + const togglePath = (path: string) => { + if (selectedPaths.includes(path)) { + onSelectionChange(selectedPaths.filter(p => p !== path)); + } else { + onSelectionChange([...selectedPaths, path]); + } + }; + + if (loading) return
Carregando repositório...
; + if (error) return
Erro ao carregar repositório: {error}
; + if (tree.length === 0) return
Nenhum arquivo encontrado no repositório.
; + + return ( +
+ {tree.map(node => ( + + ))} +
+ ); +} diff --git a/Mindforge.Web/src/components/FlashcardComponent.tsx b/Mindforge.Web/src/components/FlashcardComponent.tsx index accda7e..c65d37e 100644 --- a/Mindforge.Web/src/components/FlashcardComponent.tsx +++ b/Mindforge.Web/src/components/FlashcardComponent.tsx @@ -1,5 +1,8 @@ -import { useState, useRef } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import { MindforgeApiService, type FlashcardMode } from '../services/MindforgeApiService'; +import { FileTreeComponent } from './FileTreeComponent'; +import { Button } from './Button'; +import './FlashcardComponent.css'; // Mapping of flashcard mode to its maximum allowed amount const modeMax: Record = { @@ -9,9 +12,6 @@ const modeMax: Record = { Hyper: 130, }; -import { Button } from './Button'; -import './FlashcardComponent.css'; - function utf8ToBase64(str: string): string { const bytes = new TextEncoder().encode(str); const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join(''); @@ -19,8 +19,7 @@ function utf8ToBase64(str: string): string { } export function FlashcardComponent() { - const [text, setText] = useState(''); - const [fileName, setFileName] = useState('manual_input.md'); + const [selectedPaths, setSelectedPaths] = useState([]); const [amount, setAmount] = useState(20); const [mode, setMode] = useState('Simple'); const [loading, setLoading] = useState(false); @@ -28,30 +27,13 @@ export function FlashcardComponent() { const [success, setSuccess] = useState(false); const handleModeChange = (newMode: FlashcardMode) => { - setMode(newMode); // set the mode - setAmount(20); // set the default amount - }; - - const fileInputRef = useRef(null); - - const handleFileUpload = (e: Event) => { - const target = e.target as HTMLInputElement; - if (target.files && target.files.length > 0) { - const file = target.files[0]; - setFileName(file.name); - const reader = new FileReader(); - reader.onload = (event) => { - if (event.target?.result) { - setText(event.target.result as string); - } - }; - reader.readAsText(file); - } + setMode(newMode); + setAmount(20); }; const handleGenerate = async () => { - if (!text.trim()) { - setError('Por favor, insira algum texto ou faça upload de um arquivo para gerar os flashcards.'); + if (selectedPaths.length === 0) { + setError('Selecione pelo menos um arquivo do repositório para gerar os flashcards.'); return; } @@ -60,16 +42,25 @@ export function FlashcardComponent() { setSuccess(false); try { - const base64Content = utf8ToBase64(text); + // Fetch all selected files and merge their content + const fileContents = await Promise.all( + selectedPaths.map(path => MindforgeApiService.getFileContent(path)) + ); + + const mergedContent = fileContents.map(f => f.content).join('\n\n---\n\n'); + const mergedFileName = selectedPaths.length === 1 + ? (selectedPaths[0].split('/').pop() ?? 'merged.md') + : 'merged.md'; + + const base64Content = utf8ToBase64(mergedContent); const res = await MindforgeApiService.generateFlashcards({ fileContent: base64Content, - fileName, + fileName: mergedFileName, amount, - mode + mode, }); - const csvContent = res.result; - downloadCSV(csvContent); + downloadCSV(res.result); setSuccess(true); } catch (err: any) { setError(err.message || 'Ocorreu um erro ao gerar os flashcards.'); @@ -79,7 +70,6 @@ export function FlashcardComponent() { }; const downloadCSV = (content: string) => { - // Adicionar BOM do UTF-8 para o Excel reconhecer os caracteres corretamente const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); const blob = new Blob([bom, content], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); @@ -95,38 +85,22 @@ export function FlashcardComponent() { return (

Gerador de Flashcards

-

Crie flashcards baseados nos seus materiais de estudo rapidamente.

+

Selecione os arquivos do repositório para gerar flashcards. Múltiplos arquivos serão combinados.

- -