@page "/editor" @page "/editor/{action}" @page "/editor/{action}/{projectId:int}" @using System.Threading @inject IOllamaService OllamaService @inject IProjectService ProjectService @inject IRagService RagService @inject ISettingsService SettingsService @inject IWikipediaService WikipediaService @inject NavigationManager Navigation @inject ILocalizationService Loc @inject IAuthService AuthService @inject OllamaSettings OllamaSettings @inject OperationState OpState @inject IJSRuntime JS @implements IDisposable @rendermode InteractiveServer @attribute [Authorize] @Loc["page.title.editor"]
@if (_contentModified) { } @statusText @if (isChecking) { }
@if (showVersionDialog) { }
@if (showSpellDiff && !string.IsNullOrEmpty(correctedText)) {

@Loc["editor.spellcheck.heading"]

} else if (showVersionDiff && _selectedVersion != null) {

@Loc["editor.version.diff_title"] @_selectedVersion.Timestamp:dd.MM.yyyy HH:mm

@if (!string.IsNullOrEmpty(_selectedVersion.Comment)) {

@Loc["editor.version.label.comment"]: @_selectedVersion.Comment

}
} else if (showVersionBrowser) {

@Loc["editor.version.list_title"]

@if (_versionList == null || _versionList.Count == 0) {

@Loc["editor.version.empty"]

} else {
@foreach (var v in _versionList) {
@v.Timestamp.ToString("dd.MM.yyyy HH:mm") @if (!string.IsNullOrEmpty(v.Comment)) { @v.Comment }
}
}
} else if (ragResults != null && ragResults.Count > 0) {

@Loc["editor.rag.heading"]

@foreach (var r in ragResults) {
@Loc["editor.rag.label.similarity"] @r.Similarity.ToString("P1")
@r.FilePath
@r.Text
}
} else if (wikipediaResults != null && wikipediaResults.Count > 0) {

@Loc["editor.wikipedia.heading"]

@foreach (var r in wikipediaResults) {
@(r.Language == "de" ? "🇩🇪" : "🇬🇧") @r.Language.ToUpper()
@r.Snippet
@if (!string.IsNullOrEmpty(r.Extract)) {
@r.Extract
}
}
} else if (!string.IsNullOrEmpty(analysisResult) && !showVersionBrowser) {

@Loc["editor.result.heading"]

@analysisResult
}
@if (showCreateDialog) { } @code { [Parameter] public string? Action { get; set; } [Parameter] public int? ProjectId { get; set; } private string editorContent = string.Empty; private string searchQuery = string.Empty; private string statusText = string.Empty; private bool isChecking; private bool buttonDisabled; private double checkProgress; private string analysisResult = string.Empty; private string originalText = string.Empty; private string correctedText = string.Empty; private bool showSpellDiff; private List? ragResults; private List? wikipediaResults; private int currentProjectId; private string? currentFile; private bool showCreateDialog; private string newProjectName = string.Empty; private string newProjectPath = string.Empty; private bool _createProjectDisabled => string.IsNullOrWhiteSpace(newProjectName) || string.IsNullOrWhiteSpace(newProjectPath); private ProjectTemplate selectedTemplate = ProjectTemplate.Novel; private Dictionary loadedArticles = new(); private bool showVersionDialog; private string versionComment = string.Empty; private VersionEntry? latestVersion; private Timer? _autosaveTimer; private string _lastSavedContent = string.Empty; private bool _contentModified; private bool showVersionBrowser; private bool showVersionDiff; private List _versionList = new(); private VersionEntry? _selectedVersion; private string _versionDiffContent = string.Empty; private record VersionEntry(string FilePath, DateTime Timestamp, string? Comment); protected override async Task OnInitializedAsync() { statusText = Loc["common.status.ready"]; Navigation.LocationChanged += OnLocationChanged; await LoadProjectContext(); var pendingOutput = SettingsService.PendingAssistantOutput; if (!string.IsNullOrEmpty(pendingOutput)) { SettingsService.PendingAssistantOutput = null; editorContent = pendingOutput; _lastSavedContent = editorContent; statusText = Loc["editor.status.imported_from_assistant"]; } if (string.Equals(Action, "newproject", StringComparison.OrdinalIgnoreCase)) { newProjectName = string.Empty; newProjectPath = ProjectPathHelper.SuggestPath("Projekt"); showCreateDialog = true; } } private async void OnLocationChanged(object? sender, LocationChangedEventArgs e) { await LoadProjectContext(); StateHasChanged(); } public void Dispose() { Navigation.LocationChanged -= OnLocationChanged; _autosaveTimer?.Dispose(); } private void OnContentChanged(ChangeEventArgs e) { editorContent = e.Value?.ToString() ?? string.Empty; _contentModified = editorContent != _lastSavedContent; ResetAutosaveTimer(); StateHasChanged(); } private void ResetAutosaveTimer() { _autosaveTimer?.Dispose(); _autosaveTimer = new Timer(AutosaveCallback, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan); } private async void AutosaveCallback(object? state) { if (string.IsNullOrEmpty(currentFile) || !_contentModified || isChecking) return; try { await InvokeAsync(async () => { await File.WriteAllTextAsync(currentFile, editorContent); _lastSavedContent = editorContent; _contentModified = false; statusText = Loc["editor.status.autosaved"]; StateHasChanged(); }); } catch { } } private void OpenAssistant() { SettingsService.PendingAssistantInput = editorContent; Navigation.NavigateTo("/assistant"); } private void HandleSearchInput(ChangeEventArgs e) { searchQuery = e.Value?.ToString() ?? string.Empty; buttonDisabled = isChecking || string.IsNullOrWhiteSpace(searchQuery); StateHasChanged(); } private async Task LoadProjectContext() { if (ProjectId.HasValue) { currentProjectId = ProjectId.Value; SettingsService.CurrentProjectId = currentProjectId; } else if (SettingsService.CurrentProjectId.HasValue) { currentProjectId = SettingsService.CurrentProjectId.Value; } if (currentProjectId > 0) await SettingsService.LoadProjectSettingsAsync(); var filePath = SettingsService.CurrentFilePath; if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) { currentFile = filePath; editorContent = await File.ReadAllTextAsync(filePath); _lastSavedContent = editorContent; _contentModified = false; statusText = $"{Loc["editor.status.loaded"]} {Path.GetFileName(filePath)}"; RefreshLatestVersion(); var styleModel = await SettingsService.GetStyleModelAsync(); if (!string.IsNullOrEmpty(styleModel)) { var ctx = await OllamaService.GetModelContextWindowAsync(styleModel); if (ctx.Success && editorContent.Length > ctx.ContextWindow * 0.8) { statusText = $"⚠ {Loc["editor.warning.context"]} ({editorContent.Length} Tokens ≈ {ctx.ContextWindow} Kontext)"; } } } } private void ClearResults() { showSpellDiff = false; showVersionBrowser = false; showVersionDiff = false; ragResults = null; wikipediaResults = null; analysisResult = string.Empty; } private async Task CheckStyle() { if (string.IsNullOrWhiteSpace(editorContent)) return; isChecking = true; statusText = Loc["editor.status.analyzing_style"]; checkProgress = 50; ClearResults(); try { } catch (Exception ex) { analysisResult = $"{Loc["common.error"]} {ex.Message}"; } finally { statusText = Loc["common.status.ready"]; isChecking = false; checkProgress = 0; } } private async Task SpellCheck() { if (string.IsNullOrWhiteSpace(editorContent)) return; isChecking = true; statusText = Loc["editor.status.checking_spelling"]; checkProgress = 50; ClearResults(); try { originalText = editorContent; correctedText = ""; OpState.SetStatus(Loc["editor.status.checking_spelling"], true); OpState.SetThought(""); await OllamaService.ChatStream(OllamaSettings.SpellingModel, editorContent, async chunk => { correctedText += chunk; OpState.AppendThought(chunk); await InvokeAsync(StateHasChanged); }, OllamaSettings.SpellingPrompt, OllamaSettings.SpellingTemp); showSpellDiff = true; OpState.SetStatus(Loc["common.status.ready"], false); } catch (Exception ex) { analysisResult = $"{Loc["common.error"]} {ex.Message}"; OpState.SetStatus($"{Loc["common.error"]} {ex.Message}", false); } finally { OpState.Reset(); statusText = Loc["common.status.ready"]; isChecking = false; checkProgress = 0; } } private void ApplySpellCorrections() { if (string.IsNullOrEmpty(correctedText)) return; editorContent = correctedText; _contentModified = editorContent != _lastSavedContent; showSpellDiff = false; ResetAutosaveTimer(); statusText = Loc["editor.status.corrections_applied"]; } private async Task RagSearch() { if (string.IsNullOrWhiteSpace(searchQuery)) return; isChecking = true; statusText = Loc["editor.status.searching_rag"]; ClearResults(); analysisResult = string.Empty; try { if (currentProjectId == 0) { analysisResult = Loc["editor.rag.error.no_project"]; return; } ragResults = await RagService.SearchAsync(currentProjectId, searchQuery, 5); if (ragResults.Count == 0) { analysisResult = Loc["editor.rag.error.no_facts"]; } } catch (Exception ex) { analysisResult = $"{Loc["editor.rag.error.generic"]} {ex.Message}"; } finally { statusText = Loc["common.status.ready"]; isChecking = false; } } private async Task SearchWikipedia() { if (string.IsNullOrWhiteSpace(searchQuery)) return; isChecking = true; statusText = Loc["editor.status.searching_wikipedia"]; ClearResults(); analysisResult = string.Empty; loadedArticles.Clear(); try { var request = new WikipediaSearchRequest { Query = searchQuery, SearchGerman = true, SearchEnglish = true, MaxResults = 3 }; wikipediaResults = await WikipediaService.SearchAsync(request); foreach (var result in wikipediaResults) { var extract = await WikipediaService.GetExtractAsync(result.Title, result.Language); if (extract != null) result.Extract = extract.Length > 800 ? extract[..800] + "..." : extract; } if (wikipediaResults.Count == 0) analysisResult = Loc["editor.wikipedia.error.not_found"]; } catch (Exception ex) { analysisResult = $"{Loc["editor.wikipedia.error.generic"]} {ex.Message}"; } finally { statusText = Loc["common.status.ready"]; isChecking = false; } } private async Task LoadFullArticle(WikipediaResult result) { statusText = $"{Loc["editor.status.loading_article"]} {result.Title}..."; try { var fullText = await WikipediaService.GetFullArticleAsync(result.Title, result.Language); if (fullText != null) { result.Extract = fullText; loadedArticles[result.Url] = true; statusText = Loc["editor.wikipedia.status.loaded"]; } else { statusText = Loc["editor.wikipedia.error.load_failed"]; } } catch (Exception ex) { statusText = $"{Loc["common.error"]} {ex.Message}"; } } private bool IsSaveFactDisabled(WikipediaResult r) => string.IsNullOrWhiteSpace(r.Extract) || currentProjectId == 0; private async Task SaveAsFact(WikipediaResult result) { if (string.IsNullOrWhiteSpace(result.Extract) || currentProjectId == 0) return; statusText = Loc["editor.status.saving_fact"]; try { var projects = await ProjectService.GetAllProjectsAsync(); var project = projects.FirstOrDefault(p => p.Id == currentProjectId); if (project == null) { statusText = Loc["editor.error.project_not_found"]; return; } var factsDir = Path.Combine(project.RootPath, "01_Fakten"); Directory.CreateDirectory(factsDir); var safeName = SanitizeFilename(searchQuery); var filePath = Path.Combine(factsDir, $"{safeName}.md"); var content = $"# {result.Title}\n" + $"- Quelle: [{result.Title}]({result.Url})\n" + $"- Sprache: {result.Language.ToUpper()}\n" + $"- Abgerufen: {DateTime.Now:dd.MM.yyyy HH:mm}\n\n" + result.Extract; await File.WriteAllTextAsync(filePath, content); statusText = $"{Loc["editor.status.saved_as"]} {safeName}.md"; } catch (Exception ex) { statusText = $"{Loc["editor.error.save_failed"]} {ex.Message}"; } } private static string SanitizeFilename(string name) { var invalid = Path.GetInvalidFileNameChars(); var sanitized = new string(name.Where(c => !invalid.Contains(c)).ToArray()); return string.IsNullOrWhiteSpace(sanitized) ? "fakt" : sanitized.Trim(); } private void CancelCreateProject() { showCreateDialog = false; Navigation.NavigateTo("/editor"); } private async Task CreateProject() { if (string.IsNullOrWhiteSpace(newProjectName) || string.IsNullOrWhiteSpace(newProjectPath)) return; if (!ProjectPathHelper.IsPathAllowed(newProjectPath)) { showCreateDialog = false; statusText = $"⚠ Pfad nicht erlaubt. Verwende: {ProjectPathHelper.AllowedBasePath}"; return; } isChecking = true; statusText = Loc["editor.status.creating_project"]; try { Directory.CreateDirectory(newProjectPath); var userId = AuthService.CurrentUser?.Id ?? 0; var id = await ProjectService.CreateNewProjectAsync(newProjectName, newProjectPath, userId, selectedTemplate); SettingsService.CurrentProjectId = id; await SettingsService.LoadProjectSettingsAsync(); showCreateDialog = false; Navigation.NavigateTo($"/editor/index/{id}"); } catch (Exception ex) { analysisResult = $"{Loc["editor.error.create_failed"]} {ex.Message}"; } finally { isChecking = false; statusText = Loc["common.status.ready"]; } } private async Task IndexProject() { if (currentProjectId == 0) { analysisResult = Loc["editor.index.error.no_project"]; return; } isChecking = true; statusText = Loc["editor.status.indexing"]; analysisResult = string.Empty; ragResults = null; try { var projects = await ProjectService.GetAllProjectsAsync(); var project = projects.FirstOrDefault(p => p.Id == currentProjectId); if (project == null) { analysisResult = Loc["editor.index.error.not_found"]; return; } var p = new Progress(msg => { statusText = msg; InvokeAsync(StateHasChanged); }); await RagService.IndexProjectAsync(currentProjectId, project.RootPath, p); analysisResult = Loc["editor.index.status.success"]; } catch (Exception ex) { analysisResult = $"{Loc["editor.index.error.generic"]} {ex.Message}"; } finally { statusText = Loc["common.status.ready"]; isChecking = false; } } private async Task SaveCurrentFile() { if (string.IsNullOrEmpty(currentFile)) return; await File.WriteAllTextAsync(currentFile, editorContent); _lastSavedContent = editorContent; _contentModified = false; statusText = $"{Loc["editor.status.saved"]} {Path.GetFileName(currentFile)}"; } private void ShowVersionDialog() { showVersionDialog = true; versionComment = string.Empty; } private async Task SaveVersioned() { if (string.IsNullOrEmpty(currentFile)) return; var dir = Path.GetDirectoryName(currentFile)!; var name = Path.GetFileNameWithoutExtension(currentFile); var ext = Path.GetExtension(currentFile); var versionsDir = Path.Combine(dir, "_versions"); Directory.CreateDirectory(versionsDir); var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var versionPath = Path.Combine(versionsDir, $"{name}_{stamp}{ext}"); await File.WriteAllTextAsync(currentFile, editorContent); await File.WriteAllTextAsync(versionPath, editorContent); var meta = $"Kommentar: {versionComment}"; await File.WriteAllTextAsync(versionPath + ".meta", meta); statusText = $"{Loc["editor.status.version_saved"]} {name}_{stamp}{ext}"; showVersionDialog = false; versionComment = string.Empty; _lastSavedContent = editorContent; _contentModified = false; RefreshLatestVersion(); } private void ShowVersionBrowser() { ClearResults(); LoadVersionList(); showVersionBrowser = true; } private void CloseVersionBrowser() { showVersionBrowser = false; showVersionDiff = false; _selectedVersion = null; } private void LoadVersionList() { _versionList.Clear(); if (string.IsNullOrEmpty(currentFile)) return; var dir = Path.GetDirectoryName(currentFile)!; var name = Path.GetFileNameWithoutExtension(currentFile); var ext = Path.GetExtension(currentFile); var versionsDir = Path.Combine(dir, "_versions"); if (!Directory.Exists(versionsDir)) return; var pattern = $"{name}_*{ext}"; var files = Directory.GetFiles(versionsDir, pattern).OrderByDescending(f => f).ToList(); foreach (var file in files) { var metaFile = file + ".meta"; string? comment = null; if (File.Exists(metaFile)) { var lines = File.ReadAllLines(metaFile); if (lines.Length > 0 && lines[0].StartsWith("Kommentar: ")) comment = lines[0]["Kommentar: ".Length..]; } var fileName = Path.GetFileNameWithoutExtension(file); var stamp = fileName[(name.Length + 1)..]; var parsed = DateTime.TryParseExact(stamp, "yyyyMMdd_HHmmss", null, System.Globalization.DateTimeStyles.None, out var dt); _versionList.Add(new VersionEntry( file, parsed ? dt : File.GetLastWriteTime(file), comment )); } } private async void ShowVersionDiff(VersionEntry version) { try { _versionDiffContent = await File.ReadAllTextAsync(version.FilePath); _selectedVersion = version; showVersionDiff = true; } catch (Exception ex) { statusText = $"{Loc["common.error"]} {ex.Message}"; } } private async void RestoreVersion(VersionEntry version) { try { editorContent = await File.ReadAllTextAsync(version.FilePath); _contentModified = editorContent != _lastSavedContent; ResetAutosaveTimer(); statusText = $"{Loc["editor.version.status_loaded_prefix"]} {version.Timestamp:dd.MM.yyyy HH:mm} {Loc["editor.version.status_loaded_suffix"]}"; showVersionBrowser = false; showVersionDiff = false; _selectedVersion = null; } catch (Exception ex) { statusText = $"{Loc["common.error"]} {ex.Message}"; } } private async Task WrapSelection(string prefix, string suffix) => await JS.InvokeVoidAsync("EditorToolbar.wrapSelection", prefix, suffix); private async Task InsertHeading(int level) => await JS.InvokeVoidAsync("EditorToolbar.insertHeading", level); private async Task InsertList(bool ordered) => await JS.InvokeVoidAsync("EditorToolbar.insertList", ordered); private async Task IndentLines() => await JS.InvokeVoidAsync("EditorToolbar.indentLines"); private async Task OutdentLines() => await JS.InvokeVoidAsync("EditorToolbar.outdentLines"); private void RefreshLatestVersion() { latestVersion = null; if (string.IsNullOrEmpty(currentFile)) return; var dir = Path.GetDirectoryName(currentFile)!; var name = Path.GetFileNameWithoutExtension(currentFile); var ext = Path.GetExtension(currentFile); var versionsDir = Path.Combine(dir, "_versions"); if (!Directory.Exists(versionsDir)) return; var pattern = $"{name}_*{ext}"; var files = Directory.GetFiles(versionsDir, pattern).OrderByDescending(f => f).ToList(); if (files.Count == 0) return; var latest = files.First(); var metaFile = latest + ".meta"; string? comment = null; if (File.Exists(metaFile)) { var lines = File.ReadAllLines(metaFile); if (lines.Length > 0 && lines[0].StartsWith("Kommentar: ")) comment = lines[0]["Kommentar: ".Length..]; } var fileName = Path.GetFileNameWithoutExtension(latest); var stamp = fileName[(name.Length + 1)..]; var parsed = DateTime.TryParseExact(stamp, "yyyyMMdd_HHmmss", null, System.Globalization.DateTimeStyles.None, out var dt); latestVersion = new VersionEntry( latest, parsed ? dt : File.GetLastWriteTime(latest), comment ); } }