commit 0d77300c22cb8b9098ca929833c7d80a4b4bbd99 Author: Jacob Schmidt Date: Sat Feb 21 02:01:00 2026 -0600 feat: Project Journal backend monorepo Monorepo with centralized build props, npm workspaces, LlamaSharp AI, SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests. Co-Authored-By: Oz diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..960e0be --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +[*.cs] +# Prefer expression body for single-line constructors/methods/properties +csharp_style_expression_bodied_constructors = when_on_single_line:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a857e36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Build output +bin/ +obj/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# VS Code +.vscode/ + +# NuGet +*.nupkg +**/packages/ +project.lock.json +project.fragment.lock.json +.nuget/ +.dotnet_home/ +.journal-sidecar/ +.tmp +.npm +output/ + +# Publish output +publish/ + +# User secrets +secrets.json + +# Windows +Thumbs.db +desktop.ini + +# Runtime journal data (created by sidecar at repo root) +journal/ +logs/ + +# macOS +.DS_Store + +# Node +node_modules/ + +# OTHER +.just/ +journalapp.exe +journalapp(1).exe +.cache/ +Journal.DevTool/scripts/__pycache__/ +.sdt/ +devtool.backup.json \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..43790c8 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..9845347 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,16 @@ + + + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/Journal.AI/Coach-Rules.txt b/Journal.AI/Coach-Rules.txt new file mode 100644 index 0000000..b65dc90 --- /dev/null +++ b/Journal.AI/Coach-Rules.txt @@ -0,0 +1,35 @@ +You are a personal coach inside a private journaling app. +Your goals: +- Be supportive, practical, and brief. +- Ask at most {{maxQuestions}} questions. +- Suggest at most {{maxNextActions}} next actions. +- Default to small, realistic steps. + +Hard rules: +- Do not diagnose medical or mental health conditions. +- Do not use shame, guilt, or alarmist language. +- Do not claim certainty about the user's feelings or motives. +- Treat all suggestions as optional proposals. +- Output MUST be a single valid JSON object with NO text before or after it. +- Do NOT output explanations, templates, or examples — only the final JSON. +- If context is missing, make gentle assumptions and ask 1 clarifying question. + +Evidence: +- When making an observation or suggestion, include evidence snippets with recordId when available. + +You MUST respond with a single valid JSON object. No text before or after the JSON. + +Required JSON schema: +{ + "kind": "daily_checkin" | "evening_review" | "weekly_review", + "title": "", + "summary": "<2-4 sentence summary>", + "questions": [""], + "suggestedNextActions": [""], + "suggestedTags": [""], + "evidence": [{"recordId": null, "text": ""}], + "patchProposal": null +} +The top-level "kind" MUST be exactly one of: "daily_checkin", "evening_review", "weekly_review". +Set patchProposal to null unless a draft is clearly helpful. If needed use: {"kind": "createDraft", "description": "", "content": ""}. +Do NOT confuse patchProposal.kind with the top-level kind field. diff --git a/Journal.AI/Daily-Check-In.txt b/Journal.AI/Daily-Check-In.txt new file mode 100644 index 0000000..c879030 --- /dev/null +++ b/Journal.AI/Daily-Check-In.txt @@ -0,0 +1,20 @@ +{{CoachRules}} + +Task: +Generate a DAILY CHECK-IN plan for date {{dateLocal}}. + +Context (JSON): +{{contextJson}} + +Preferences (JSON): +{{preferencesJson}} + +Output: +Return a CoachPlanDto JSON with kind="daily_checkin". +Include: +- A short title +- A 2-4 sentence summary +- 1-{{maxQuestions}} questions +- 1-{{maxNextActions}} suggestedNextActions +- suggestedTags if relevant +- patchProposal that creates a fragment or dailyEntry draft ONLY if it is clearly helpful. \ No newline at end of file diff --git a/Journal.AI/Evening-Review.txt b/Journal.AI/Evening-Review.txt new file mode 100644 index 0000000..788198e --- /dev/null +++ b/Journal.AI/Evening-Review.txt @@ -0,0 +1,19 @@ +{{CoachRules}} + +Task: +Generate an EVENING REVIEW for date {{dateLocal}}. + +Context (JSON): +{{contextJson}} + +Preferences (JSON): +{{preferencesJson}} + +Output: +Return a CoachPlanDto JSON with kind="evening_review". +Include: +- Brief recap of today +- 2-4 observations with evidence +- 1-{{maxQuestions}} reflection questions +- 1-{{maxNextActions}} next actions for tomorrow +- patchProposal that creates: (optional) tomorrow-intent fragment AND/OR todos. \ No newline at end of file diff --git a/Journal.AI/Journal.AI.csproj b/Journal.AI/Journal.AI.csproj new file mode 100644 index 0000000..32ff915 --- /dev/null +++ b/Journal.AI/Journal.AI.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Journal.AI/LlamaSharpAiService.cs b/Journal.AI/LlamaSharpAiService.cs new file mode 100644 index 0000000..0844659 --- /dev/null +++ b/Journal.AI/LlamaSharpAiService.cs @@ -0,0 +1,329 @@ +using System.Text; +using System.Text.RegularExpressions; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Services.Ai; +using LLama; +using LLama.Common; +using LLama.Sampling; + +namespace Journal.AI; + +public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiService, IDisposable +{ + private const string DefaultModelUrl = + "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-q4.gguf"; + private const string DefaultModelFileName = "Phi-3-mini-4k-instruct-q4.gguf"; + private const string ModelSubDirectory = "ai-models"; + + private readonly string _configuredModelPath = config.GgufModelPath; + private readonly uint _contextSize = (uint)Math.Clamp(config.ModelContextTokens, 512, 4096); + private readonly int _gpuLayers = config.LlamaCppTimeout; + + private readonly Lock _sync = new(); + private string? _resolvedModelPath; + private LLamaWeights? _weights; + private bool _disposed; + + public Task HealthAsync(CancellationToken cancellationToken = default) + { + var resolved = _resolvedModelPath ?? _configuredModelPath; + var modelExists = File.Exists(resolved) || File.Exists(GetDefaultModelPath()); + var loaded = _weights is not null; + return Task.FromResult(new AiHealthDto( + Provider: "llamasharp", + Enabled: true, + Healthy: modelExists || loaded, + Message: loaded + ? "Model loaded." + : modelExists + ? "Model found (will load on first use)." + : "Model not found locally. It will be downloaded on first use.")); + } + + private static string BuildChatSystemPrompt() + { + var dateStr = DateTime.Now.ToString("MMMM d, yyyy"); + return $"You are a supportive conversational coach inside a private journaling app. " + + $"Today's date is {dateStr}. " + + $"Reply in plain natural language only. Never output JSON, code blocks, or structured data. " + + $"Be warm, practical, and concise. Do not repeat yourself."; + } + + public async Task ChatAsync(string prompt, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required.", nameof(prompt)); + + var raw = await RunSessionAsync(prompt, BuildChatSystemPrompt(), + maxTokens: 512, cancellationToken: cancellationToken); + return CleanChatResponse(raw); + } + + public async Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, + string prompt, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required.", nameof(prompt)); + + var modelPath = await EnsureModelAsync(cancellationToken); + EnsureWeights(modelPath); + + using var context = _weights!.CreateContext(new ModelParams(modelPath) + { + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + + var executor = new StatelessExecutor(_weights!, context.Params); + + var sb = new StringBuilder(); + sb.Append($"<|system|>\n{BuildChatSystemPrompt()}<|end|>\n"); + + foreach (var (role, text) in history) + { + var tag = string.Equals(role, "user", StringComparison.OrdinalIgnoreCase) ? "user" : "assistant"; + sb.Append($"<|{tag}|>\n{text}<|end|>\n"); + } + + sb.Append($"<|user|>\n{prompt}<|end|>\n"); + sb.Append("<|assistant|>\n"); + + var inferenceParams = new InferenceParams + { + MaxTokens = 512, + AntiPrompts = ["<|user|>", "<|system|>", "<|end|>", "<|endoftext|>"], + SamplingPipeline = new DefaultSamplingPipeline { Temperature = 0.7f } + }; + + var result = new StringBuilder(); + await foreach (var token in executor.InferAsync(sb.ToString(), inferenceParams, cancellationToken)) + { + result.Append(token); + } + + return CleanChatResponse(StripSpecialTokens(result.ToString())); + } + + internal async Task ChatJsonAsync(string prompt, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required.", nameof(prompt)); + + var dateStr = DateTime.Now.ToString("MMMM d, yyyy"); + return await RunSessionAsync(prompt, + $"You are a coaching assistant inside a private journaling app. " + + $"Today's date is {dateStr}. " + + $"You MUST respond with ONLY a single valid JSON object. " + + $"Do NOT write any text, explanation, or commentary before or after the JSON. " + + $"Output MUST start with {{ and end with }}.", + maxTokens: 2048, temperature: 0.2f, cancellationToken: cancellationToken); + } + + public async Task SummarizeEntryAsync(string content, string? fileStem = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Entry content is required.", nameof(content)); + + var prompt = fileStem is not null + ? $"Summarize this journal entry ({fileStem}) concisely:\n\n{content}" + : $"Summarize this journal entry concisely:\n\n{content}"; + + return await ChatAsync(prompt, cancellationToken); + } + + public async Task SummarizeAllAsync(IReadOnlyList entries, + CancellationToken cancellationToken = default) + { + if (entries is null || entries.Count == 0) + return "No entries to summarize."; + + var combined = string.Join("\n\n---\n\n", entries); + var prompt = $"Summarize the following {entries.Count} journal entries into a concise overview:\n\n{combined}"; + + return await ChatAsync(prompt, cancellationToken); + } + + public async Task> EmbedAsync(string content, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Content is required.", nameof(content)); + + var modelPath = await EnsureModelAsync(cancellationToken); + + try + { + EnsureWeights(modelPath); + var embedder = new LLamaEmbedder(_weights!, new ModelParams(modelPath) + { + Embeddings = true, + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + + var embeddingArrays = await embedder.GetEmbeddings(content, cancellationToken); + var result = new List(); + + foreach (var arr in embeddingArrays) + { + foreach (var val in arr) + { + result.Add(val); + } + } + + return result; + } + catch + { + return []; + } + } + + // ── Model download (mirrors LocalWhisperS2TService.EnsureModelAsync) ─── + + private static string GetDefaultModelPath() + { + var modelDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ProjectJournal", + ModelSubDirectory); + return Path.Combine(modelDirectory, DefaultModelFileName); + } + + private async Task EnsureModelAsync(CancellationToken cancellationToken = default) + { + if (File.Exists(_configuredModelPath)) + return _configuredModelPath; + + var defaultPath = GetDefaultModelPath(); + if (File.Exists(defaultPath)) + return defaultPath; + + var modelDirectory = Path.GetDirectoryName(defaultPath)!; + Directory.CreateDirectory(modelDirectory); + + var tempPath = defaultPath + ".download"; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(30)); + + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromMinutes(30); + + using var response = await httpClient.GetAsync(DefaultModelUrl, + HttpCompletionOption.ResponseHeadersRead, cts.Token); + response.EnsureSuccessStatusCode(); + + await using var contentStream = await response.Content.ReadAsStreamAsync(cts.Token); + await using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None); + await contentStream.CopyToAsync(fileStream, cts.Token); + await fileStream.FlushAsync(cts.Token); + fileStream.Close(); + + File.Move(tempPath, defaultPath, overwrite: true); + return defaultPath; + } + + // ── Session / weights lifecycle ──────────────────────────────────────── + + private Task RunSessionAsync(string prompt, string systemPrompt, + int maxTokens, CancellationToken cancellationToken) + => RunSessionAsync(prompt, systemPrompt, maxTokens, temperature: 0.7f, cancellationToken); + + private async Task RunSessionAsync(string prompt, string systemPrompt, + int maxTokens, float temperature, CancellationToken cancellationToken) + { + var modelPath = await EnsureModelAsync(cancellationToken); + EnsureWeights(modelPath); + + using var context = _weights!.CreateContext(new ModelParams(modelPath) + { + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + + var executor = new StatelessExecutor(_weights!, context.Params); + + var fullPrompt = $"<|system|>\n{systemPrompt}<|end|>\n" + + $"<|user|>\n{prompt}<|end|>\n" + + $"<|assistant|>\n"; + + var inferenceParams = new InferenceParams + { + MaxTokens = maxTokens, + AntiPrompts = [ + "<|user|>", + "<|system|>", + "<|end|>", + "<|endoftext|>", + ], + SamplingPipeline = new DefaultSamplingPipeline + { + Temperature = temperature + } + }; + + var sb = new StringBuilder(); + + await foreach (var token in executor.InferAsync(fullPrompt, inferenceParams, cancellationToken)) + { + sb.Append(token); + } + + return StripSpecialTokens(sb.ToString()); + } + + private static string StripSpecialTokens(string raw) + { + var text = raw; + foreach (var marker in new[] { "<|assistant|>", "<|user|>", "<|system|>", "<|end|>", "<|endoftext|>" }) + text = text.Replace(marker, ""); + return text.Trim(); + } + + private static readonly Regex RoleMarkerRegex = MyRegex(); + + private static string CleanChatResponse(string raw) + { + var text = StripSpecialTokens(raw); + + text = RoleMarkerRegex.Replace(text, ""); + text = text.Replace("**", ""); + text = MyRegex2().Replace(text, "\n\n"); + + return text.Trim(); + } + + private void EnsureWeights(string modelPath) + { + if (_weights is not null) return; + + lock (_sync) + { + if (_weights is not null) return; + + _resolvedModelPath = modelPath; + _weights = LLamaWeights.LoadFromFile(new ModelParams(modelPath) + { + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _weights?.Dispose(); + } + + [GeneratedRegex(@"\*{0,2}(System|Assistant|User):\*{0,2}", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex MyRegex(); + [GeneratedRegex(@"\n{3,}")] + private static partial Regex MyRegex1(); + [GeneratedRegex(@"\n{3,}")] + private static partial Regex MyRegex2(); +} diff --git a/Journal.AI/LlamaSharpCoachService.cs b/Journal.AI/LlamaSharpCoachService.cs new file mode 100644 index 0000000..78e7e70 --- /dev/null +++ b/Journal.AI/LlamaSharpCoachService.cs @@ -0,0 +1,229 @@ +using System.Reflection; +using System.Text; +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Services.Ai; + +namespace Journal.AI; + +public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly LlamaSharpAiService _ai = ai; + private readonly string _coachRules = LoadEmbeddedResource("Coach-Rules.txt"); + private readonly string _dailyTemplate = LoadEmbeddedResource("Daily-Check-In.txt"); + private readonly string _eveningTemplate = LoadEmbeddedResource("Evening-Review.txt"); + private readonly string _weeklyTemplate = LoadEmbeddedResource("Weekly-Review.txt"); + + public async Task DailyCheckInAsync(CoachContextDto context, + CancellationToken cancellationToken = default) + { + var prefs = context.Preferences ?? new CoachPreferencesDto(); + var prompt = InterpolateTemplate(_dailyTemplate, context, prefs); + return await RunCoachPromptAsync(prompt, "daily_checkin", cancellationToken); + } + + public async Task EveningReviewAsync(CoachContextDto context, + CancellationToken cancellationToken = default) + { + var prefs = context.Preferences ?? new CoachPreferencesDto(); + var prompt = InterpolateTemplate(_eveningTemplate, context, prefs); + return await RunCoachPromptAsync(prompt, "evening_review", cancellationToken); + } + + public async Task WeeklyReviewAsync(CoachContextDto context, + CancellationToken cancellationToken = default) + { + var prefs = context.Preferences ?? new CoachPreferencesDto(); + var prompt = InterpolateTemplate(_weeklyTemplate, context, prefs); + return await RunCoachPromptAsync(prompt, "weekly_review", cancellationToken); + } + + private async Task RunCoachPromptAsync(string prompt, string fallbackKind, + CancellationToken cancellationToken) + { + var raw = await _ai.ChatJsonAsync(prompt, cancellationToken); + var json = ExtractJson(raw); + if (json is not null) + { + try + { + var parsed = JsonSerializer.Deserialize(json, JsonOptions); + if (parsed is not null) + return parsed with { Kind = fallbackKind }; + } + catch (JsonException) + { + + } + } + + return new CoachPlanDto( + Kind: fallbackKind, + Title: "Coach Response", + Summary: raw, + Questions: [], + SuggestedNextActions: [], + SuggestedTags: [], + Evidence: []); + } + + private string InterpolateTemplate(string template, CoachContextDto context, CoachPreferencesDto prefs) + { + var contextJson = JsonSerializer.Serialize(new + { + recentEntries = context.RecentEntries ?? [], + recentFragments = context.RecentFragments ?? [] + }); + + var preferencesJson = JsonSerializer.Serialize(new + { + prefs.MaxQuestions, + prefs.MaxNextActions + }); + + var result = template + .Replace("{{CoachRules}}", _coachRules) + .Replace("{{dateLocal}}", context.DateLocal) + .Replace("{{weekStartLocal}}", context.WeekStartLocal ?? "") + .Replace("{{weekEndLocal}}", context.WeekEndLocal ?? "") + .Replace("{{contextJson}}", contextJson) + .Replace("{{preferencesJson}}", preferencesJson) + .Replace("{{maxQuestions}}", prefs.MaxQuestions.ToString()) + .Replace("{{maxNextActions}}", prefs.MaxNextActions.ToString()); + + return result; + } + + private static string? ExtractJson(string text) + { + var codeBlockStart = text.IndexOf("```json", StringComparison.Ordinal); + if (codeBlockStart >= 0) + { + var jsonStart = text.IndexOf('{', codeBlockStart); + var codeBlockEnd = text.IndexOf("```", codeBlockStart + 7, StringComparison.Ordinal); + if (jsonStart >= 0 && codeBlockEnd > jsonStart) + { + var candidate = text[jsonStart..codeBlockEnd].Trim(); + if (TryValidateJson(candidate)) + return candidate; + } + } + + var kindMarker = text.IndexOf("{\"kind\"", StringComparison.Ordinal); + if (kindMarker < 0) + kindMarker = text.IndexOf("{ \"kind\"", StringComparison.Ordinal); + if (kindMarker >= 0) + { + var lastBrace = text.LastIndexOf('}'); + if (lastBrace > kindMarker) + { + var candidate = text[kindMarker..(lastBrace + 1)]; + if (TryValidateJson(candidate)) + return candidate; + } + } + + var searchFrom = 0; + var globalLastBrace = text.LastIndexOf('}'); + while (searchFrom < text.Length && globalLastBrace > searchFrom) + { + var bracePos = text.IndexOf('{', searchFrom); + if (bracePos < 0 || bracePos >= globalLastBrace) + break; + + var candidate = text[bracePos..(globalLastBrace + 1)]; + if (TryValidateJson(candidate)) + return candidate; + + searchFrom = bracePos + 1; + } + + var firstBrace = text.IndexOf('{'); + if (firstBrace >= 0) + { + var repaired = TryRepairJson(text[firstBrace..]); + if (repaired is not null) + return repaired; + } + + return null; + } + + private static string? TryRepairJson(string text) + { + var trimmed = text.TrimEnd(); + var lastUseful = trimmed.Length - 1; + while (lastUseful >= 0) + { + var ch = trimmed[lastUseful]; + if (ch is '}' or ']' or '"' or ',' or ':' or '{' or '[' || char.IsDigit(ch) + || ch is 't' or 'r' or 'u' or 'e' or 'f' or 'a' or 'l' or 's' or 'n') + break; + lastUseful--; + } + + if (lastUseful < 0) return null; + trimmed = trimmed[..(lastUseful + 1)]; + + if (trimmed.EndsWith(',')) + trimmed = trimmed[..^1]; + + var openBraces = 0; + var openBrackets = 0; + var inString = false; + var escape = false; + + foreach (var ch in trimmed) + { + if (escape) { escape = false; continue; } + if (ch == '\\' && inString) { escape = true; continue; } + if (ch == '"') { inString = !inString; continue; } + if (inString) continue; + + switch (ch) + { + case '{': openBraces++; break; + case '}': openBraces--; break; + case '[': openBrackets++; break; + case ']': openBrackets--; break; + } + } + + if (openBraces <= 0 && openBrackets <= 0) return null; + + var sb = new StringBuilder(trimmed); + for (var i = 0; i < openBrackets; i++) sb.Append(']'); + for (var i = 0; i < openBraces; i++) sb.Append('}'); + + var repaired = sb.ToString(); + return TryValidateJson(repaired) ? repaired : null; + } + + private static bool TryValidateJson(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.ValueKind == JsonValueKind.Object; + } + catch + { + return false; + } + } + + private static string LoadEmbeddedResource(string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)) ?? throw new FileNotFoundException($"Embedded resource not found: {fileName}"); + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd().Trim(); + } +} diff --git a/Journal.AI/ServiceCollectionExtensions.cs b/Journal.AI/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..48ddaa6 --- /dev/null +++ b/Journal.AI/ServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +using Journal.Core.Services.Ai; +using Journal.Core.Services.Config; +using Microsoft.Extensions.DependencyInjection; + +namespace Journal.AI; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers LLamaSharp-based AI and Coach services. + /// Call this AFTER AddFragmentServices() so that IJournalConfigService is available. + /// When the provider is "llamasharp", this replaces the default IAiService registration. + /// + public static IServiceCollection AddLlamaSharpServices(this IServiceCollection services) + { + // Override IAiService — last registration wins in MS DI + services.AddSingleton(provider => + { + var config = provider.GetRequiredService().Current; + + if (string.Equals(config.AiProvider, "llamasharp", StringComparison.OrdinalIgnoreCase)) + { + try + { + return new LlamaSharpAiService(config); + } + catch (Exception ex) + { + return new DisabledAiService( + provider: "llamasharp", + message: $"LLamaSharp unavailable: {ex.Message}", + healthy: false); + } + } + + return new DisabledAiService(config.AiProvider); + }); + + // Register coach service + services.AddSingleton(provider => + { + var config = provider.GetRequiredService().Current; + var ai = provider.GetRequiredService(); + + if (ai is LlamaSharpAiService llamaAi) + return new LlamaSharpCoachService(llamaAi); + + if (string.Equals(config.AiProvider, "none", StringComparison.OrdinalIgnoreCase)) + return new DisabledCoachService(); + + return new DisabledCoachService( + $"Coach requires llamasharp provider (current: {config.AiProvider})."); + }); + + return services; + } +} diff --git a/Journal.AI/Weekly-Review.txt b/Journal.AI/Weekly-Review.txt new file mode 100644 index 0000000..8e1c52a --- /dev/null +++ b/Journal.AI/Weekly-Review.txt @@ -0,0 +1,19 @@ +{{CoachRules}} + +Task: +Generate a WEEKLY REVIEW for {{weekStartLocal}} to {{weekEndLocal}}. + +Context (JSON): +{{contextJson}} + +Preferences (JSON): +{{preferencesJson}} + +Output: +Return a CoachPlanDto JSON with kind="weekly_review". +Include: +- Themes (top 2-4) and blockers (top 1-3) +- Wins (top 1-3) +- 1-{{maxQuestions}} questions to set next week’s focus +- 2-{{maxNextActions}} suggestedNextActions +- patchProposal should only propose creating todos or a planning fragment, not editing old entries. \ No newline at end of file diff --git a/Journal.App/.gitignore b/Journal.App/.gitignore new file mode 100644 index 0000000..6635cf5 --- /dev/null +++ b/Journal.App/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/Journal.App/.prettierignore b/Journal.App/.prettierignore new file mode 100644 index 0000000..ca17681 --- /dev/null +++ b/Journal.App/.prettierignore @@ -0,0 +1,8 @@ +node_modules/ +build/ +.svelte-kit/ +.vscode/ +dist/ +coverage/ +target/ +src-tauri/target/ diff --git a/Journal.App/README.md b/Journal.App/README.md new file mode 100644 index 0000000..aa3ddaa --- /dev/null +++ b/Journal.App/README.md @@ -0,0 +1,77 @@ +# Journal.App + +SvelteKit 5 + Tauri 2 desktop application for Project Journal. + +## Tech Stack + +- **Frontend**: SvelteKit 5, TypeScript, Vite 6 +- **Tauri shell**: Rust (Tauri 2), `tokio` async runtime +- **Backend bridge**: `Journal.Sidecar.exe` managed as a persistent long-lived child process + +## Dev Setup + +```powershell +npm install +npm run dev # SvelteKit dev server at http://localhost:1420 +npm run tauri dev # Tauri desktop window (connects to dev server) +``` + +## Build Targets + +| Command | Output | Use case | +| ------------------------------------------------------------ | ----------------------------------------- | ----------------------------------- | +| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` | +| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script | +| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe | +| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer | + +## Frontend State Management + +Svelte stores are the source of truth for all feature state. + +### Current Stores + +| Store file | State exports | Notes | +| ----------------------------- | --------------------------------------- | ------------------------------------------------- | +| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` | +| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers | +| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD | +| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` | +| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config | + +### Store-First Rule + +- Components call **store helper functions** for CRUD operations — not inline mutations. +- Components should focus on rendering, local form state, and invoking store operations. +- Backend calls (`sendCommand`) belong inside store/service helpers, not components. + +## Tauri Commands (Rust → Frontend) + +| Command | Description | +| ------------------ | ------------------------------------------------------------------------------------ | +| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON | +| `get_sidecar_root` | Get currently resolved sidecar root path | +| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) | +| `get_ui_settings` | Load tag/fragment-type settings | +| `set_ui_settings` | Persist tag/fragment-type settings | +| `shutdown` | Stop sidecar, exit app | + +## Sidecar Path Resolution + +The Rust shell looks for `Journal.Sidecar.exe` starting from the auto-detected repository root: + +1. `/Journal.Sidecar.exe` +2. `/publish/Journal.Sidecar.exe` +3. `/Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe` +4. `/Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe` +5. Recursive scan of `/Journal.Sidecar/` + +Build the sidecar before running the Tauri app: + +```powershell +.\scripts\publish-sidecar.ps1 -Configuration Release -Runtime win-x64 +``` + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) diff --git a/Journal.App/package.json b/Journal.App/package.json new file mode 100644 index 0000000..6e88438 --- /dev/null +++ b/Journal.App/package.json @@ -0,0 +1,36 @@ +{ + "name": "journalapp", + "version": "0.1.0", + "description": "", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "tauri:prebuild": "node ./scripts/tauri-prebuild.mjs", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "tauri": "tauri", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "license": "MIT", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-opener": "^2", + "tauri-plugin-mic-recorder-api": "^2.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tauri-apps/cli": "^2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } +} diff --git a/Journal.App/prettier.config.cjs b/Journal.App/prettier.config.cjs new file mode 100644 index 0000000..282b589 --- /dev/null +++ b/Journal.App/prettier.config.cjs @@ -0,0 +1,11 @@ +module.exports = { + plugins: ["prettier-plugin-svelte"], + overrides: [ + { + files: "*.svelte", + options: { + parser: "svelte", + }, + }, + ], +}; diff --git a/Journal.App/scripts/tauri-prebuild.mjs b/Journal.App/scripts/tauri-prebuild.mjs new file mode 100644 index 0000000..170ec2b --- /dev/null +++ b/Journal.App/scripts/tauri-prebuild.mjs @@ -0,0 +1,97 @@ +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const appRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(appRoot, ".."); + +const sidecarProject = path.join( + repoRoot, + "Journal.Sidecar", + "Journal.Sidecar.csproj", +); +const publishOutputDir = path.join(repoRoot, "output"); +const tauriBinDir = path.join(appRoot, "src-tauri", "bin"); + +function runtimeForCurrentPlatform() { + const arch = process.arch; + if (process.platform === "win32") { + if (arch === "arm64") return "win-arm64"; + return "win-x64"; + } + if (process.platform === "linux") { + if (arch === "arm64") return "linux-arm64"; + return "linux-x64"; + } + if (process.platform === "darwin") { + if (arch === "arm64") return "osx-arm64"; + return "osx-x64"; + } + throw new Error( + `Unsupported platform '${process.platform}' for sidecar publish.`, + ); +} + +function sidecarFileName() { + return process.platform === "win32" + ? "Journal.Sidecar.exe" + : "Journal.Sidecar"; +} + +function publishProject(projectPath, runtime) { + const publishArgs = [ + "publish", + projectPath, + "-c", + "Release", + "-r", + runtime, + "--self-contained", + "-p:PublishSingleFile=true", + "-p:IncludeNativeLibrariesForSelfExtract=true", + "-p:IncludeAllContentForSelfExtract=true", + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + "-o", + publishOutputDir, + ]; + + const publish = spawnSync("dotnet", publishArgs, { + cwd: repoRoot, + stdio: "inherit", + }); + + if (publish.error) { + throw publish.error; + } + if (publish.status !== 0) { + process.exit(publish.status ?? 1); + } +} + +function stageBinary(fileName) { + const publishedBinary = path.join(publishOutputDir, fileName); + const bundledBinary = path.join(tauriBinDir, fileName); + + if (!existsSync(publishedBinary)) { + throw new Error(`Published binary not found: ${publishedBinary}`); + } + + mkdirSync(tauriBinDir, { recursive: true }); + copyFileSync(publishedBinary, bundledBinary); + console.log(`Staged binary for Tauri: ${bundledBinary}`); +} + +const runtime = runtimeForCurrentPlatform(); +const sidecarName = sidecarFileName(); + +console.log( + `Publishing sidecar for ${process.platform}/${process.arch} (${runtime})...`, +); + +console.log("Publishing Journal.Sidecar..."); +publishProject(sidecarProject, runtime); +stageBinary(sidecarName); diff --git a/Journal.App/src-tauri/.gitignore b/Journal.App/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/Journal.App/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/Journal.App/src-tauri/Cargo.lock b/Journal.App/src-tauri/Cargo.lock new file mode 100644 index 0000000..95b410b --- /dev/null +++ b/Journal.App/src-tauri/Cargo.lock @@ -0,0 +1,5777 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "journalapp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-mic-recorder", + "tauri-plugin-opener", + "tokio", +] + +[[package]] +name = "js-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.13.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk 0.9.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-mic-recorder" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceccca393df4f90abba52602125d526c71ddd0557b03ac7dd9c3f818325b6d95" +dependencies = [ + "chrono", + "clap", + "cpal", + "hound", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk 0.9.0", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.14", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.14", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.14", +] diff --git a/Journal.App/src-tauri/Cargo.toml b/Journal.App/src-tauri/Cargo.toml new file mode 100644 index 0000000..1f39d74 --- /dev/null +++ b/Journal.App/src-tauri/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "journalapp" +version = "0.1.0" +description = "A Tauri App" +authors = ["Stan", "J. Schmidt"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "journalapp_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-opener = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["process", "io-util", "sync", "time"] } +tauri-plugin-mic-recorder = "2.0.0" diff --git a/Journal.App/src-tauri/build.rs b/Journal.App/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/Journal.App/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/Journal.App/src-tauri/capabilities/default.json b/Journal.App/src-tauri/capabilities/default.json new file mode 100644 index 0000000..275d58e --- /dev/null +++ b/Journal.App/src-tauri/capabilities/default.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "dialog:default", + "opener:default", + "mic-recorder:default" + ] +} diff --git a/Journal.App/src-tauri/icons/128x128.png b/Journal.App/src-tauri/icons/128x128.png new file mode 100644 index 0000000..2d911cd Binary files /dev/null and b/Journal.App/src-tauri/icons/128x128.png differ diff --git a/Journal.App/src-tauri/icons/128x128@2x.png b/Journal.App/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..8d4a5e8 Binary files /dev/null and b/Journal.App/src-tauri/icons/128x128@2x.png differ diff --git a/Journal.App/src-tauri/icons/32x32.png b/Journal.App/src-tauri/icons/32x32.png new file mode 100644 index 0000000..7b41a78 Binary files /dev/null and b/Journal.App/src-tauri/icons/32x32.png differ diff --git a/Journal.App/src-tauri/icons/64x64.png b/Journal.App/src-tauri/icons/64x64.png new file mode 100644 index 0000000..9286e8d Binary files /dev/null and b/Journal.App/src-tauri/icons/64x64.png differ diff --git a/Journal.App/src-tauri/icons/Square107x107Logo.png b/Journal.App/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..a195b03 Binary files /dev/null and b/Journal.App/src-tauri/icons/Square107x107Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square142x142Logo.png b/Journal.App/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..a52d49b Binary files /dev/null and b/Journal.App/src-tauri/icons/Square142x142Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square150x150Logo.png b/Journal.App/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..97bf71f Binary files /dev/null and b/Journal.App/src-tauri/icons/Square150x150Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square284x284Logo.png b/Journal.App/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..9388caf Binary files /dev/null and b/Journal.App/src-tauri/icons/Square284x284Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square30x30Logo.png b/Journal.App/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..6fe3e8a Binary files /dev/null and b/Journal.App/src-tauri/icons/Square30x30Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square310x310Logo.png b/Journal.App/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..a15a3f9 Binary files /dev/null and b/Journal.App/src-tauri/icons/Square310x310Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square44x44Logo.png b/Journal.App/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..3ac2272 Binary files /dev/null and b/Journal.App/src-tauri/icons/Square44x44Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square71x71Logo.png b/Journal.App/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..90d71f5 Binary files /dev/null and b/Journal.App/src-tauri/icons/Square71x71Logo.png differ diff --git a/Journal.App/src-tauri/icons/Square89x89Logo.png b/Journal.App/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..731faa5 Binary files /dev/null and b/Journal.App/src-tauri/icons/Square89x89Logo.png differ diff --git a/Journal.App/src-tauri/icons/StoreLogo.png b/Journal.App/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..860f9c2 Binary files /dev/null and b/Journal.App/src-tauri/icons/StoreLogo.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/Journal.App/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/Journal.App/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..5f45f4d Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..825369d Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..42faa23 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d37d7af Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ba45af7 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..7d67494 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..b503076 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df1c2f3 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..6413a9d Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..feb3485 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9a4e6a8 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..3ecdfea Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..6802d92 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d49935f Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4ac2a01 Binary files /dev/null and b/Journal.App/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Journal.App/src-tauri/icons/android/values/ic_launcher_background.xml b/Journal.App/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/Journal.App/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/Journal.App/src-tauri/icons/icon.icns b/Journal.App/src-tauri/icons/icon.icns new file mode 100644 index 0000000..722efed Binary files /dev/null and b/Journal.App/src-tauri/icons/icon.icns differ diff --git a/Journal.App/src-tauri/icons/icon.ico b/Journal.App/src-tauri/icons/icon.ico new file mode 100644 index 0000000..d7d5e76 Binary files /dev/null and b/Journal.App/src-tauri/icons/icon.ico differ diff --git a/Journal.App/src-tauri/icons/icon.png b/Journal.App/src-tauri/icons/icon.png new file mode 100644 index 0000000..a1712ec Binary files /dev/null and b/Journal.App/src-tauri/icons/icon.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-20x20@1x.png b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..d46466a Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..4c47628 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x.png b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..4c47628 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-20x20@3x.png b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..b9e2fcc Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-29x29@1x.png b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..b459572 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..536bc3e Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x.png b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..536bc3e Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-29x29@3x.png b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..f1ddfd7 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-40x40@1x.png b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..4c47628 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..4348b83 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x.png b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..4348b83 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-40x40@3x.png b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..70c1a37 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-512@2x.png b/Journal.App/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..768a82f Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-60x60@2x.png b/Journal.App/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..70c1a37 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-60x60@3x.png b/Journal.App/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..68b754d Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-76x76@1x.png b/Journal.App/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..8da1fb1 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-76x76@2x.png b/Journal.App/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..af1c309 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/Journal.App/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/Journal.App/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..6127e87 Binary files /dev/null and b/Journal.App/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/Journal.App/src-tauri/src/lib.rs b/Journal.App/src-tauri/src/lib.rs new file mode 100644 index 0000000..8991a5e --- /dev/null +++ b/Journal.App/src-tauri/src/lib.rs @@ -0,0 +1,709 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tauri::{Emitter, Manager}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CommandEnvelope { + action: String, + #[serde(default)] + correlation_id: Option, + #[serde(default)] + id: Option, + #[serde(default)] + r#type: Option, + #[serde(default)] + tag: Option, + #[serde(default)] + payload: Option, +} + +const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"]; +const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"]; +const DEFAULT_STARTUP_VIEW: &str = "entries"; + +#[derive(Deserialize, Serialize)] +struct AppSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + sidecar_root: Option, + #[serde(default = "default_settings_tags")] + tags: Vec, + #[serde(default = "default_fragment_types")] + fragment_types: Vec, + #[serde(default = "default_startup_view")] + default_startup_view: String, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + sidecar_root: None, + tags: default_settings_tags(), + fragment_types: default_fragment_types(), + default_startup_view: default_startup_view(), + } + } +} + +fn default_settings_tags() -> Vec { + DEFAULT_SETTINGS_TAGS + .iter() + .map(|v| (*v).to_string()) + .collect() +} + +fn default_fragment_types() -> Vec { + DEFAULT_FRAGMENT_TYPES + .iter() + .map(|v| (*v).to_string()) + .collect() +} + +fn default_startup_view() -> String { + DEFAULT_STARTUP_VIEW.to_string() +} + +fn normalize_items(values: Vec, fallback: &[&str]) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + for item in values { + let trimmed = item.trim(); + if trimmed.is_empty() { + continue; + } + let key = trimmed.to_lowercase(); + if seen.insert(key) { + normalized.push(trimmed.to_string()); + } + } + + if normalized.is_empty() { + return fallback.iter().map(|v| (*v).to_string()).collect(); + } + + normalized +} + +fn normalize_startup_view(value: Option) -> String { + let normalized = value + .unwrap_or_else(default_startup_view) + .trim() + .to_lowercase(); + match normalized.as_str() { + "entries" | "calendar" | "fragments" | "todos" | "lists" => normalized, + _ => default_startup_view(), + } +} + +struct ManagedSidecar { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +struct ManagedSpeechProcess { + poll_task: tokio::task::JoinHandle<()>, +} + +impl ManagedSidecar { + fn start(root: &Path, resource_dir: Option<&Path>) -> Result { + let sidecar_path = resolve_sidecar_path(root, resource_dir)?; + let mut cmd = Command::new(sidecar_path); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .current_dir(root) + .env("JOURNAL_PROJECT_ROOT", root) + .kill_on_drop(true); + #[cfg(windows)] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + let mut child = cmd + .spawn() + .map_err(|err| format!("Failed to start sidecar process: {err}"))?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| "Unable to open sidecar stdin.".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Unable to open sidecar stdout.".to_string())?; + + Ok(Self { + child, + stdin, + stdout: BufReader::new(stdout), + }) + } + + fn is_running(&mut self) -> bool { + match self.child.try_wait() { + Ok(None) => true, + Ok(Some(_)) => false, + Err(_) => false, + } + } + + async fn send_command_line(&mut self, input_line: &str) -> Result { + self.stdin + .write_all(format!("{input_line}\n").as_bytes()) + .await + .map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?; + self.stdin + .flush() + .await + .map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?; + + let mut response_line = String::new(); + let read = self + .stdout + .read_line(&mut response_line) + .await + .map_err(|err| format!("Failed reading sidecar stdout: {err}"))?; + if read == 0 { + return Err("Sidecar stdout closed unexpectedly.".to_string()); + } + + let trimmed = response_line.trim().to_string(); + if trimmed.is_empty() { + return Err("Sidecar returned an empty response line.".to_string()); + } + + Ok(trimmed) + } +} + +impl Drop for ManagedSidecar { + fn drop(&mut self) {} +} + +impl ManagedSpeechProcess { + fn is_running(&self) -> bool { + !self.poll_task.is_finished() + } +} + +struct SidecarState { + process: Mutex>, + speech_process: Mutex>, + root_override: Mutex>, + config_path: PathBuf, + resource_dir: Option, +} + +fn load_settings(path: &Path) -> AppSettings { + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> { + let json = serde_json::to_string_pretty(settings) + .map_err(|e| format!("Failed to serialize settings: {e}"))?; + fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}")) +} + +fn auto_detect_root() -> Result { + let mut current = + env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?; + loop { + if current.join("Journal.Sidecar").exists() { + return Ok(current); + } + if !current.pop() { + return Err("Unable to locate repository root containing Journal.Sidecar.".to_string()); + } + } +} + +fn effective_root(root_override: &Option) -> Result { + if let Some(root) = root_override { + return Ok(root.clone()); + } + auto_detect_root() +} + +fn resolve_sidecar_path(root: &Path, resource_dir: Option<&Path>) -> Result { + #[cfg(windows)] + let exe_name = "Journal.Sidecar.exe"; + #[cfg(not(windows))] + let exe_name = "Journal.Sidecar"; + + if root.is_file() && root.file_name().and_then(|n| n.to_str()) == Some(exe_name) { + return Ok(root.to_path_buf()); + } + + let root_exe_path = root.join(exe_name); + if root_exe_path.exists() { + return Ok(root_exe_path); + } + + let tauri_bin_sidecar_path = root + .join("Journal.App") + .join("src-tauri") + .join("bin") + .join(exe_name); + if tauri_bin_sidecar_path.exists() { + return Ok(tauri_bin_sidecar_path); + } + + let sidecar_src_root = root.join("Journal.Sidecar"); + if let Some(path) = find_sidecar_executable(&sidecar_src_root, exe_name) { + return Ok(path); + } + + if let Some(resource_dir) = resource_dir { + let resource_sidecar_path = resource_dir.join("bin").join(exe_name); + if resource_sidecar_path.exists() { + return Ok(resource_sidecar_path); + } + } + + Err(format!( + "{exe_name} not found in root, Journal.Sidecar tree, or resource dir for {}.", + root.display() + )) +} + +fn parse_command_response(response_line: &str) -> Result { + serde_json::from_str::(response_line) + .map_err(|err| format!("Invalid sidecar JSON response: {err}")) +} + +fn read_field<'a>(data: &'a Value, camel: &str, pascal: &str) -> Option<&'a Value> { + data.get(camel).or_else(|| data.get(pascal)) +} + +fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option { + if !search_root.is_dir() { + return None; + } + + let mut stack = vec![search_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name == "node_modules" || name == ".git" || name == ".vs" { + continue; + } + } + stack.push(path); + continue; + } + + let is_sidecar_exe = path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case(exe_name)) + .unwrap_or(false); + if is_sidecar_exe { + return Some(path); + } + } + } + + None +} + +async fn send_with_managed_sidecar( + state: &SidecarState, + input_line: &str, +) -> Result { + let root = { + let root_override = state.root_override.lock().await; + effective_root(&root_override)? + }; + let mut guard = state.process.lock().await; + + for attempt in 1..=2 { + let should_start = match guard.as_mut() { + Some(existing) => !existing.is_running(), + None => true, + }; + if should_start { + *guard = Some(ManagedSidecar::start(&root, state.resource_dir.as_deref())?); + } + + let Some(process) = guard.as_mut() else { + return Err("Sidecar process unavailable.".to_string()); + }; + + match process.send_command_line(input_line).await { + Ok(line) => return Ok(line), + Err(err) => { + *guard = None; + if attempt == 2 { + return Err(err); + } + } + } + } + + Err("Failed to send command to sidecar.".to_string()) +} + +async fn send_sidecar_action( + state: &SidecarState, + action: &str, + payload: Option, +) -> Result { + let envelope = serde_json::json!({ + "action": action, + "payload": payload.unwrap_or_else(|| serde_json::json!({})) + }); + let input_line = serde_json::to_string(&envelope) + .map_err(|err| format!("Serialize command failed: {err}"))?; + let response_line = send_with_managed_sidecar(state, &input_line).await?; + let response = parse_command_response(&response_line)?; + + let ok = response + .get("ok") + .and_then(|node| node.as_bool()) + .unwrap_or(false); + if !ok { + let err = response + .get("error") + .and_then(|node| node.as_str()) + .unwrap_or("Sidecar command failed."); + return Err(err.to_string()); + } + + Ok(response + .get("data") + .cloned() + .unwrap_or_else(|| serde_json::json!({}))) +} + +async fn stop_managed_sidecar(state: &SidecarState) { + let mut guard = state.process.lock().await; + guard.take(); +} + +async fn stop_speech_process(state: &SidecarState) -> Result<(), String> { + let mut guard = state.speech_process.lock().await; + if let Some(process) = guard.take() { + process.poll_task.abort(); + } + + Ok(()) +} + +#[tauri::command] +async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result { + let root_override = state.root_override.lock().await.clone(); + let root = effective_root(&root_override)?; + Ok(serde_json::json!({ + "root": root.to_string_lossy(), + "isCustom": root_override.is_some() + })) +} + +#[tauri::command] +async fn set_sidecar_root( + state: tauri::State<'_, SidecarState>, + path: String, +) -> Result { + let (new_override, root) = if path.trim().is_empty() { + let detected = auto_detect_root()?; + (None, detected) + } else { + let new_root = PathBuf::from(&path); + if !new_root.exists() { + return Err(format!( + "Directory '{}' does not exist.", + new_root.display() + )); + } + resolve_sidecar_path(&new_root, state.resource_dir.as_deref())?; + (Some(new_root.clone()), new_root) + }; + + // Stop the current sidecar so it restarts with new root + { + let mut guard = state.process.lock().await; + guard.take(); + } + + let is_custom = new_override.is_some(); + *state.root_override.lock().await = new_override.clone(); + + let mut settings = load_settings(&state.config_path); + settings.sidecar_root = new_override.map(|p| p.to_string_lossy().into_owned()); + save_settings(&state.config_path, &settings)?; + + Ok(serde_json::json!({ + "root": root.to_string_lossy(), + "isCustom": is_custom + })) +} + +#[tauri::command] +async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result { + let settings = load_settings(&state.config_path); + let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS); + let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES); + let startup_view = normalize_startup_view(Some(settings.default_startup_view)); + + Ok(serde_json::json!({ + "tags": tags, + "fragmentTypes": fragment_types, + "defaultStartupView": startup_view + })) +} + +#[tauri::command] +async fn set_ui_settings( + state: tauri::State<'_, SidecarState>, + tags: Vec, + fragment_types: Vec, + default_startup_view: Option, +) -> Result { + let mut settings = load_settings(&state.config_path); + settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS); + settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES); + settings.default_startup_view = normalize_startup_view(default_startup_view); + save_settings(&state.config_path, &settings)?; + + Ok(serde_json::json!({ + "tags": settings.tags, + "fragmentTypes": settings.fragment_types, + "defaultStartupView": settings.default_startup_view + })) +} + +#[tauri::command] +async fn shutdown( + state: tauri::State<'_, SidecarState>, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + stop_speech_process(state.inner()).await?; + let _ = send_sidecar_action(state.inner(), "speech.live.stop", None).await; + stop_managed_sidecar(state.inner()).await; + app_handle.exit(0); + Ok(()) +} + +#[tauri::command] +async fn speech_start( + state: tauri::State<'_, SidecarState>, + app_handle: tauri::AppHandle, +) -> Result { + let _ = app_handle.emit( + "speech-status", + serde_json::json!({ "state": "starting", "message": "Starting speech process..." }), + ); + + { + let guard = state.speech_process.lock().await; + if let Some(existing) = guard.as_ref() { + if existing.is_running() { + return Ok(serde_json::json!({ "running": true })); + } + } + } + + let start_data = send_sidecar_action(state.inner(), "speech.live.start", None).await?; + let running = read_field(&start_data, "running", "Running") + .and_then(|node| node.as_bool()) + .unwrap_or(false); + let status = read_field(&start_data, "status", "Status") + .and_then(|node| node.as_str()) + .unwrap_or("starting"); + let warning = read_field(&start_data, "warning", "Warning") + .and_then(|node| node.as_str()) + .map(|v| v.to_string()); + + let _ = app_handle.emit( + "speech-status", + serde_json::json!({ "state": status, "message": warning.clone().unwrap_or_else(|| status.to_string()) }), + ); + + if !running { + return Err(warning.unwrap_or_else(|| "Failed to start live speech.".to_string())); + } + + let app_for_poll = app_handle.clone(); + let poll_task = tokio::spawn(async move { + loop { + let state_handle = app_for_poll.state::(); + let poll_data = match send_sidecar_action( + state_handle.inner(), + "speech.live.poll", + Some(serde_json::json!({ "maxItems": 8 })), + ) + .await + { + Ok(value) => value, + Err(err) => { + let _ = app_for_poll.emit( + "speech-status", + serde_json::json!({ "state": "error", "message": err }), + ); + break; + } + }; + + if let Some(items) = + read_field(&poll_data, "items", "Items").and_then(|node| node.as_array()) + { + for item in items { + if let Some(text) = item.as_str() { + let _ = app_for_poll + .emit("speech-transcript", serde_json::json!({ "text": text })); + } + } + } + + let running = read_field(&poll_data, "running", "Running") + .and_then(|node| node.as_bool()) + .unwrap_or(false); + let status = read_field(&poll_data, "status", "Status") + .and_then(|node| node.as_str()) + .unwrap_or(if running { "listening" } else { "stopped" }); + let warning = read_field(&poll_data, "warning", "Warning") + .and_then(|node| node.as_str()) + .map(|v| v.to_string()); + if let Some(message) = warning { + let _ = app_for_poll.emit( + "speech-status", + serde_json::json!({ "state": if running { "listening" } else { "error" }, "message": message }), + ); + } else { + let _ = app_for_poll.emit( + "speech-status", + serde_json::json!({ "state": status, "message": status }), + ); + } + + if !running { + break; + } + + tokio::time::sleep(std::time::Duration::from_millis(350)).await; + } + }); + + let mut guard = state.speech_process.lock().await; + *guard = Some(ManagedSpeechProcess { poll_task }); + Ok(serde_json::json!({ "running": true })) +} + +#[tauri::command] +async fn speech_stop( + state: tauri::State<'_, SidecarState>, + app_handle: tauri::AppHandle, +) -> Result { + stop_speech_process(state.inner()).await?; + let _ = send_sidecar_action(state.inner(), "speech.live.stop", None).await; + let _ = app_handle.emit( + "speech-status", + serde_json::json!({ "state": "stopped", "message": "Dictation stopped." }), + ); + Ok(serde_json::json!({ "running": false })) +} + +#[tauri::command] +async fn speech_cleanup_probe(path: String) -> Result { + if path.trim().is_empty() { + return Ok(serde_json::json!({ "deleted": false })); + } + + let target = PathBuf::from(path); + let normalized = target.to_string_lossy().to_lowercase(); + if !normalized.contains("tauri-plugin-mic-recorder") || !normalized.ends_with(".wav") { + return Ok(serde_json::json!({ "deleted": false })); + } + + if !target.exists() { + return Ok(serde_json::json!({ "deleted": false })); + } + + fs::remove_file(&target).map_err(|err| format!("Failed to remove probe recording: {err}"))?; + + Ok(serde_json::json!({ "deleted": true })) +} + +#[tauri::command] +async fn sidecar_command( + state: tauri::State<'_, SidecarState>, + command: CommandEnvelope, +) -> Result { + if command.action.trim().is_empty() { + return Err("Missing action".to_string()); + } + + let input_line = serde_json::to_string(&command) + .map_err(|err| format!("Serialize command failed: {err}"))?; + let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?; + parse_command_response(&response_line) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let app = tauri::Builder::default() + .plugin(tauri_plugin_mic_recorder::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) + .invoke_handler(tauri::generate_handler![ + sidecar_command, + shutdown, + speech_start, + speech_stop, + speech_cleanup_probe, + get_sidecar_root, + set_sidecar_root, + get_ui_settings, + set_ui_settings, + ]) + .setup(|app| { + let config_dir = app.path().app_config_dir()?; + fs::create_dir_all(&config_dir).ok(); + let config_path = config_dir.join("settings.json"); + let settings = load_settings(&config_path); + let root_override = settings.sidecar_root.map(PathBuf::from); + + app.manage(SidecarState { + process: Mutex::new(None), + speech_process: Mutex::new(None), + root_override: Mutex::new(root_override), + config_path, + resource_dir: app.path().resource_dir().ok(), + }); + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|app_handle, event| { + if let tauri::RunEvent::ExitRequested { .. } = event { + let state = app_handle.state::(); + if let Ok(mut guard) = state.process.try_lock() { + guard.take(); + }; + if let Ok(mut guard) = state.speech_process.try_lock() { + if let Some(speech) = guard.take() { + speech.poll_task.abort(); + } + }; + } + }); +} diff --git a/Journal.App/src-tauri/src/main.rs b/Journal.App/src-tauri/src/main.rs new file mode 100644 index 0000000..2c417cb --- /dev/null +++ b/Journal.App/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + journalapp_lib::run() +} diff --git a/Journal.App/src-tauri/tauri.conf.json b/Journal.App/src-tauri/tauri.conf.json new file mode 100644 index 0000000..ad09961 --- /dev/null +++ b/Journal.App/src-tauri/tauri.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Project Journal", + "version": "0.1.0", + "identifier": "com.idsolutions.journal", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run tauri:prebuild && npm run build", + "frontendDist": "../build" + }, + "app": { + "windows": [ + { + "title": "Project Journal", + "width": 1366, + "height": 768 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "resources": ["bin"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/Journal.App/src/app.html b/Journal.App/src/app.html new file mode 100644 index 0000000..5e2e21c --- /dev/null +++ b/Journal.App/src/app.html @@ -0,0 +1,19 @@ + + + + + + + + + Journal + %sveltekit.head% + + + +
%sveltekit.body%
+ + diff --git a/Journal.App/src/lib/backend/ai.ts b/Journal.App/src/lib/backend/ai.ts new file mode 100644 index 0000000..a0da357 --- /dev/null +++ b/Journal.App/src/lib/backend/ai.ts @@ -0,0 +1,222 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +//#region Public Types +export type AiHealthDto = { + provider: string; + enabled: boolean; + healthy: boolean; + message: string; +}; + +export type CoachEvidenceDto = { + recordId: string | null; + text: string; +}; + +export type CoachPatchProposalDto = { + kind: string; + description: string | null; + content: string | null; +}; + +export type CoachPlanDto = { + kind: string; + title: string; + summary: string; + questions: string[]; + suggestedNextActions: string[]; + suggestedTags: string[]; + evidence: CoachEvidenceDto[]; + patchProposal: CoachPatchProposalDto | null; +}; + +export type CoachPreferencesDto = { + maxQuestions?: number; + maxNextActions?: number; +}; + +export type CoachSessionPayload = { + dateLocal?: string; + weekStartLocal?: string; + weekEndLocal?: string; + recentEntries?: string[]; + recentFragments?: string[]; + preferences?: CoachPreferencesDto; +}; +//#endregion + +//#region PascalCase Normalizers +type AiHealthDtoRaw = { + provider?: string; + enabled?: boolean; + healthy?: boolean; + message?: string; + Provider?: string; + Enabled?: boolean; + Healthy?: boolean; + Message?: string; +}; + +type CoachEvidenceDtoRaw = { + recordId?: string | null; + text?: string; + RecordId?: string | null; + Text?: string; +}; + +type CoachPatchProposalDtoRaw = { + kind?: string; + description?: string | null; + content?: string | null; + Kind?: string; + Description?: string | null; + Content?: string | null; +}; + +type CoachPlanDtoRaw = { + kind?: string; + title?: string; + summary?: string; + questions?: string[]; + suggestedNextActions?: string[]; + suggestedTags?: string[]; + evidence?: CoachEvidenceDtoRaw[]; + patchProposal?: CoachPatchProposalDtoRaw | null; + Kind?: string; + Title?: string; + Summary?: string; + Questions?: string[]; + SuggestedNextActions?: string[]; + SuggestedTags?: string[]; + Evidence?: CoachEvidenceDtoRaw[]; + PatchProposal?: CoachPatchProposalDtoRaw | null; +}; +//#endregion + +//#region Normalizers +function normalizeHealth(raw: AiHealthDtoRaw): AiHealthDto { + return { + provider: pickCase(raw, "provider", "Provider", ""), + enabled: pickCase(raw, "enabled", "Enabled", false), + healthy: pickCase(raw, "healthy", "Healthy", false), + message: pickCase(raw, "message", "Message", ""), + }; +} + +function normalizeEvidence(raw: CoachEvidenceDtoRaw): CoachEvidenceDto { + return { + recordId: pickCase(raw, "recordId", "RecordId", null as string | null), + text: pickCase(raw, "text", "Text", ""), + }; +} + +function normalizePatchProposal( + raw: CoachPatchProposalDtoRaw | null | undefined, +): CoachPatchProposalDto | null { + if (!raw) return null; + return { + kind: pickCase(raw, "kind", "Kind", ""), + description: pickCase( + raw, + "description", + "Description", + null as string | null, + ), + content: pickCase(raw, "content", "Content", null as string | null), + }; +} + +function normalizeCoachPlan(raw: CoachPlanDtoRaw): CoachPlanDto { + const evidenceRaw = pickCase( + raw, + "evidence", + "Evidence", + [] as CoachEvidenceDtoRaw[], + ); + const patchRaw = pickCase( + raw, + "patchProposal", + "PatchProposal", + null as CoachPatchProposalDtoRaw | null, + ); + + return { + kind: pickCase(raw, "kind", "Kind", ""), + title: pickCase(raw, "title", "Title", ""), + summary: pickCase(raw, "summary", "Summary", ""), + questions: pickCase(raw, "questions", "Questions", [] as string[]), + suggestedNextActions: pickCase( + raw, + "suggestedNextActions", + "SuggestedNextActions", + [] as string[], + ), + suggestedTags: pickCase( + raw, + "suggestedTags", + "SuggestedTags", + [] as string[], + ), + evidence: evidenceRaw.map(normalizeEvidence), + patchProposal: normalizePatchProposal(patchRaw), + }; +} +//#endregion + +//#region API Functions +export async function aiHealth(): Promise { + const data = await sendCommand({ + action: "ai.health", + payload: {}, + }); + return normalizeHealth(data); +} + +export async function aiChat(prompt: string): Promise { + return sendCommand({ + action: "ai.chat", + payload: { prompt }, + }); +} + +export async function aiSummarizeEntry( + content: string, + fileStem?: string, +): Promise { + return sendCommand({ + action: "ai.summarize_entry", + payload: { content, fileStem }, + }); +} + +export async function coachDaily( + payload: CoachSessionPayload = {}, +): Promise { + const data = await sendCommand({ + action: "ai.coach.daily", + payload, + }); + return normalizeCoachPlan(data); +} + +export async function coachEvening( + payload: CoachSessionPayload = {}, +): Promise { + const data = await sendCommand({ + action: "ai.coach.evening", + payload, + }); + return normalizeCoachPlan(data); +} + +export async function coachWeekly( + payload: CoachSessionPayload = {}, +): Promise { + const data = await sendCommand({ + action: "ai.coach.weekly", + payload, + }); + return normalizeCoachPlan(data); +} +//#endregion diff --git a/Journal.App/src/lib/backend/auth.ts b/Journal.App/src/lib/backend/auth.ts new file mode 100644 index 0000000..40e9ac5 --- /dev/null +++ b/Journal.App/src/lib/backend/auth.ts @@ -0,0 +1,84 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; +export function hydrateWorkspace(password: string): Promise { + return sendCommand({ + action: "db.hydrate_workspace", + payload: { password }, + }); +} + +type RuntimeConfigRaw = { + vaultDirectory?: string; + VaultDirectory?: string; +}; + +type RuntimeConfig = { + vaultDirectory: string; +}; + +type PersistOptions = { + keepalive?: boolean; +}; + +async function getRuntimeConfig( + options: PersistOptions = {}, +): Promise { + const data = await sendCommand( + { + action: "config.get", + }, + options, + ); + + return { + vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", ""), + }; +} + +export async function unlockVaultWorkspace(password: string): Promise { + const config = await getRuntimeConfig(); + const loaded = await sendCommand({ + action: "vault.load_all", + payload: { + password, + vaultDirectory: config.vaultDirectory, + }, + }); + + if (!loaded) { + throw new Error("Incorrect vault password."); + } + + await sendCommand({ + action: "db.hydrate_workspace", + payload: { + password, + }, + }); +} + +export async function persistAndClearVault( + password: string, + options: PersistOptions = {}, +): Promise { + const config = await getRuntimeConfig(options); + + await sendCommand( + { + action: "vault.rebuild_all", + payload: { + password, + vaultDirectory: config.vaultDirectory, + }, + }, + options, + ); + + await sendCommand( + { + action: "vault.clear_data_directory", + payload: {}, + }, + options, + ); +} diff --git a/Journal.App/src/lib/backend/client.ts b/Journal.App/src/lib/backend/client.ts new file mode 100644 index 0000000..81b5dc2 --- /dev/null +++ b/Journal.App/src/lib/backend/client.ts @@ -0,0 +1,30 @@ +import { invoke } from "$lib/runtime/invoke"; +import type { BackendCommand, BackendResponse } from "./types"; + +function newCorrelationId(): string { + return `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +type SendCommandOptions = { + keepalive?: boolean; +}; + +export async function sendCommand( + command: BackendCommand, + options: SendCommandOptions = {}, +): Promise { + const envelope: BackendCommand = { + ...command, + correlationId: command.correlationId ?? newCorrelationId(), + }; + const response = await invoke>("sidecar_command", { + command: envelope, + keepalive: options.keepalive === true, + }); + + if (!response.ok) { + throw new Error(response.error || "Backend command failed"); + } + + return response.data; +} diff --git a/Journal.App/src/lib/backend/conversations.ts b/Journal.App/src/lib/backend/conversations.ts new file mode 100644 index 0000000..402653a --- /dev/null +++ b/Journal.App/src/lib/backend/conversations.ts @@ -0,0 +1,195 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +//#region Public Types +export type ConversationDto = { + id: string; + title: string; + createdAt: string; + updatedAt: string; +}; + +export type ConversationMessageDto = { + id: string; + role: string; + text: string; + createdAt: string; +}; + +export type ConversationDetailDto = { + id: string; + title: string; + messages: ConversationMessageDto[]; + createdAt: string; + updatedAt: string; +}; + +export type ConversationChatResult = { + userMessage: ConversationMessageDto; + assistantMessage: ConversationMessageDto; +}; +//#endregion + +//#region PascalCase Normalizers +type ConversationDtoRaw = { + id?: string; + title?: string; + createdAt?: string; + updatedAt?: string; + Id?: string; + Title?: string; + CreatedAt?: string; + UpdatedAt?: string; +}; + +type ConversationMessageDtoRaw = { + id?: string; + role?: string; + text?: string; + createdAt?: string; + Id?: string; + Role?: string; + Text?: string; + CreatedAt?: string; +}; + +type ConversationDetailDtoRaw = { + id?: string; + title?: string; + messages?: ConversationMessageDtoRaw[]; + createdAt?: string; + updatedAt?: string; + Id?: string; + Title?: string; + Messages?: ConversationMessageDtoRaw[]; + CreatedAt?: string; + UpdatedAt?: string; +}; + +type ConversationChatResultRaw = { + userMessage?: ConversationMessageDtoRaw; + assistantMessage?: ConversationMessageDtoRaw; + UserMessage?: ConversationMessageDtoRaw; + AssistantMessage?: ConversationMessageDtoRaw; +}; +//#endregion + +//#region Normalizers +function normalizeMessage( + raw: ConversationMessageDtoRaw, +): ConversationMessageDto { + return { + id: pickCase(raw, "id", "Id", ""), + role: pickCase(raw, "role", "Role", ""), + text: pickCase(raw, "text", "Text", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + }; +} + +function normalizeConversation(raw: ConversationDtoRaw): ConversationDto { + return { + id: pickCase(raw, "id", "Id", ""), + title: pickCase(raw, "title", "Title", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""), + }; +} + +function normalizeDetail(raw: ConversationDetailDtoRaw): ConversationDetailDto { + const msgs = pickCase( + raw, + "messages", + "Messages", + [] as ConversationMessageDtoRaw[], + ); + return { + id: pickCase(raw, "id", "Id", ""), + title: pickCase(raw, "title", "Title", ""), + messages: msgs.map(normalizeMessage), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""), + }; +} + +function normalizeChatResult( + raw: ConversationChatResultRaw, +): ConversationChatResult { + const userRaw = pickCase( + raw, + "userMessage", + "UserMessage", + {} as ConversationMessageDtoRaw, + ); + const assistantRaw = pickCase( + raw, + "assistantMessage", + "AssistantMessage", + {} as ConversationMessageDtoRaw, + ); + return { + userMessage: normalizeMessage(userRaw), + assistantMessage: normalizeMessage(assistantRaw), + }; +} +//#endregion + +//#region API Functions +export async function listConversations(): Promise { + const data = await sendCommand({ + action: "conversations.list", + payload: {}, + }); + return (data ?? []).map(normalizeConversation); +} + +export async function getConversation( + id: string, +): Promise { + const data = await sendCommand({ + action: "conversations.get", + id, + payload: {}, + }); + return normalizeDetail(data); +} + +export async function createConversation( + title: string, +): Promise { + const data = await sendCommand({ + action: "conversations.create", + payload: { title }, + }); + return normalizeConversation(data); +} + +export async function updateConversation( + id: string, + title: string, +): Promise { + return sendCommand({ + action: "conversations.update", + id, + payload: { title }, + }); +} + +export async function deleteConversation(id: string): Promise { + return sendCommand({ + action: "conversations.delete", + id, + payload: {}, + }); +} + +export async function conversationChat( + conversationId: string, + prompt: string, +): Promise { + const data = await sendCommand({ + action: "conversations.chat", + payload: { conversationId, prompt }, + }); + return normalizeChatResult(data); +} +//#endregion diff --git a/Journal.App/src/lib/backend/entries.ts b/Journal.App/src/lib/backend/entries.ts new file mode 100644 index 0000000..58a52ae --- /dev/null +++ b/Journal.App/src/lib/backend/entries.ts @@ -0,0 +1,270 @@ +import { sendCommand } from "./client"; +import { + normalizeFragment, + type FragmentDto, + type FragmentDtoRaw, +} from "./fragments"; +import { pickCase } from "./normalize"; + +export type ParsedSectionDto = { + title: string; + content: string[]; + checkboxes: Record; +}; + +export type JournalEntryDto = { + date: string; + fragments: FragmentDto[]; + rawContent: string; + sections: Record; +}; + +export type EntryListItemDto = { + fileName: string; + filePath: string; +}; + +export type EntryLoadResultDto = { + fileName: string; + filePath: string; + entry: JournalEntryDto; +}; + +export type EntrySaveResultDto = { + filePath: string; +}; + +export type EntrySearchRequestDto = { + query?: string; + section?: string; + startDate?: string; + endDate?: string; + tags?: string[]; + types?: string[]; + checked?: string[]; + unchecked?: string[]; +}; + +export type EntrySearchResultDto = { + fileName: string; + entry: JournalEntryDto; +}; + +type ParsedSectionDtoRaw = { + title?: string; + content?: string[]; + checkboxes?: Record; + Title?: string; + Content?: string[]; + Checkboxes?: Record; +}; + +type JournalEntryDtoRaw = { + date?: string; + fragments?: FragmentDtoRaw[]; + rawContent?: string; + sections?: Record; + Date?: string; + Fragments?: FragmentDtoRaw[]; + RawContent?: string; + Sections?: Record; +}; + +type EntryListItemDtoRaw = { + fileName?: string; + filePath?: string; + FileName?: string; + FilePath?: string; +}; + +type EntryLoadResultDtoRaw = { + fileName?: string; + filePath?: string; + entry?: JournalEntryDtoRaw; + date?: string; + rawContent?: string; + FileName?: string; + FilePath?: string; + Entry?: JournalEntryDtoRaw; + Date?: string; + RawContent?: string; +}; + +type EntrySaveResultDtoRaw = { + filePath?: string; + FilePath?: string; +}; + +type EntrySearchResultDtoRaw = { + fileName?: string; + entry?: JournalEntryDtoRaw; + date?: string; + rawContent?: string; + FileName?: string; + Entry?: JournalEntryDtoRaw; + Date?: string; + RawContent?: string; +}; + +function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto { + return { + title: pickCase(raw, "title", "Title", ""), + content: pickCase(raw, "content", "Content", [] as string[]), + checkboxes: pickCase( + raw, + "checkboxes", + "Checkboxes", + {} as Record, + ), + }; +} + +function normalizeJournalEntry( + raw: JournalEntryDtoRaw | undefined, +): JournalEntryDto { + const fragments = pickCase( + raw, + "fragments", + "Fragments", + [] as FragmentDtoRaw[], + ); + const sections = pickCase( + raw, + "sections", + "Sections", + {} as Record, + ); + return { + date: pickCase(raw, "date", "Date", ""), + fragments: fragments.map(normalizeFragment), + rawContent: pickCase(raw, "rawContent", "RawContent", ""), + sections: Object.fromEntries( + Object.entries(sections).map(([key, value]) => [ + key, + normalizeSection(value), + ]), + ), + }; +} + +function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto { + return { + fileName: pickCase(raw, "fileName", "FileName", ""), + filePath: pickCase(raw, "filePath", "FilePath", ""), + }; +} + +function normalizeEntryLoadResult( + raw: EntryLoadResultDtoRaw, +): EntryLoadResultDto { + const nestedEntry = pickCase( + raw, + "entry", + "Entry", + undefined as JournalEntryDtoRaw | undefined, + ); + const entry = nestedEntry + ? normalizeJournalEntry(nestedEntry) + : normalizeJournalEntry({ + date: pickCase(raw, "date", "Date", undefined as string | undefined), + rawContent: pickCase( + raw, + "rawContent", + "RawContent", + undefined as string | undefined, + ), + fragments: [], + sections: {}, + }); + + return { + fileName: pickCase(raw, "fileName", "FileName", ""), + filePath: pickCase(raw, "filePath", "FilePath", ""), + entry, + }; +} + +function normalizeEntrySearchResult( + raw: EntrySearchResultDtoRaw, +): EntrySearchResultDto { + const nestedEntry = pickCase( + raw, + "entry", + "Entry", + undefined as JournalEntryDtoRaw | undefined, + ); + const entry = nestedEntry + ? normalizeJournalEntry(nestedEntry) + : normalizeJournalEntry({ + date: pickCase(raw, "date", "Date", undefined as string | undefined), + rawContent: pickCase( + raw, + "rawContent", + "RawContent", + undefined as string | undefined, + ), + fragments: [], + sections: {}, + }); + + return { + fileName: pickCase(raw, "fileName", "FileName", ""), + entry, + }; +} + +export async function listEntries(): Promise { + const data = await sendCommand({ + action: "entries.list", + payload: {}, + }); + + return data + .map(normalizeEntryListItem) + .filter((item) => Boolean(item.filePath)); +} + +export async function loadEntry(filePath: string): Promise { + const data = await sendCommand({ + action: "entries.load", + payload: { filePath }, + }); + + return normalizeEntryLoadResult(data); +} + +export async function saveEntry(payload: { + content: string; + filePath?: string; + mode?: string; + fileName?: string; +}): Promise { + const data = await sendCommand({ + action: "entries.save", + payload, + }); + + return { + filePath: pickCase(data, "filePath", "FilePath", ""), + }; +} + +export async function deleteEntry(filePath: string): Promise { + return sendCommand({ + action: "entries.delete", + payload: { filePath }, + }); +} + +export async function searchEntries( + payload: EntrySearchRequestDto, +): Promise { + const data = await sendCommand({ + action: "search.entries", + payload, + }); + + return data + .map(normalizeEntrySearchResult) + .filter((item) => Boolean(item.fileName)); +} diff --git a/Journal.App/src/lib/backend/fragments.ts b/Journal.App/src/lib/backend/fragments.ts new file mode 100644 index 0000000..fdd03a2 --- /dev/null +++ b/Journal.App/src/lib/backend/fragments.ts @@ -0,0 +1,91 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +export type FragmentDto = { + id: string; + type: string; + description: string; + time: string; + tags: string[]; +}; + +export type CreateFragmentPayload = { + type: string; + description: string; + tags?: string[]; +}; + +export type UpdateFragmentPayload = { + type?: string; + description?: string; + tags?: string[]; + time?: string; +}; + +export type FragmentDtoRaw = { + id?: string; + type?: string; + description?: string; + time?: string; + tags?: string[]; + Id?: string; + Type?: string; + Description?: string; + Time?: string; + Tags?: string[]; +}; + +export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto { + return { + id: pickCase(raw, "id", "Id", ""), + type: pickCase(raw, "type", "Type", ""), + description: pickCase(raw, "description", "Description", ""), + time: pickCase(raw, "time", "Time", ""), + tags: pickCase(raw, "tags", "Tags", [] as string[]), + }; +} + +export async function listFragments(): Promise { + const data = await sendCommand({ + action: "fragments.list", + }); + return data.map(normalizeFragment).filter((item) => Boolean(item.id)); +} + +export async function getFragment(id: string): Promise { + const data = await sendCommand({ + action: "fragments.get", + id, + }); + if (!data) return null; + const normalized = normalizeFragment(data); + return normalized.id ? normalized : null; +} + +export async function createFragment( + payload: CreateFragmentPayload, +): Promise { + const data = await sendCommand({ + action: "fragments.create", + payload, + }); + return normalizeFragment(data); +} + +export function updateFragment( + id: string, + payload: UpdateFragmentPayload, +): Promise { + return sendCommand({ + action: "fragments.update", + id, + payload, + }); +} + +export function deleteFragment(id: string): Promise { + return sendCommand({ + action: "fragments.delete", + id, + }); +} diff --git a/Journal.App/src/lib/backend/lists.ts b/Journal.App/src/lib/backend/lists.ts new file mode 100644 index 0000000..037c3f4 --- /dev/null +++ b/Journal.App/src/lib/backend/lists.ts @@ -0,0 +1,88 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +export type ListDocumentDto = { + id: string; + label: string; + content: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateListPayload = { + label: string; + content?: string; +}; + +export type UpdateListPayload = { + label?: string; + content?: string; +}; + +type ListDocumentDtoRaw = { + id?: string; + label?: string; + content?: string; + createdAt?: string; + updatedAt?: string; + Id?: string; + Label?: string; + Content?: string; + CreatedAt?: string; + UpdatedAt?: string; +}; + +export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto { + return { + id: pickCase(raw, "id", "Id", ""), + label: pickCase(raw, "label", "Label", ""), + content: pickCase(raw, "content", "Content", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""), + }; +} + +export async function listLists(): Promise { + const data = await sendCommand({ + action: "lists.list", + }); + return data.map(normalizeList).filter((item) => Boolean(item.id)); +} + +export async function getList(id: string): Promise { + const data = await sendCommand({ + action: "lists.get", + id, + }); + if (!data) return null; + const normalized = normalizeList(data); + return normalized.id ? normalized : null; +} + +export async function createList( + payload: CreateListPayload, +): Promise { + const data = await sendCommand({ + action: "lists.create", + payload, + }); + return normalizeList(data); +} + +export function updateList( + id: string, + payload: UpdateListPayload, +): Promise { + return sendCommand({ + action: "lists.update", + id, + payload, + }); +} + +export function deleteList(id: string): Promise { + return sendCommand({ + action: "lists.delete", + id, + }); +} diff --git a/Journal.App/src/lib/backend/normalize.ts b/Journal.App/src/lib/backend/normalize.ts new file mode 100644 index 0000000..40a6413 --- /dev/null +++ b/Journal.App/src/lib/backend/normalize.ts @@ -0,0 +1,20 @@ +type UnknownObject = Record; + +function asObject(value: unknown): UnknownObject | undefined { + return value && typeof value === "object" + ? (value as UnknownObject) + : undefined; +} + +export function pickCase( + source: unknown, + camelKey: string, + pascalKey: string, + fallback: T, +): T { + const obj = asObject(source); + if (!obj) return fallback; + + const value = obj[camelKey] ?? obj[pascalKey]; + return (value as T | undefined) ?? fallback; +} diff --git a/Journal.App/src/lib/backend/speech.ts b/Journal.App/src/lib/backend/speech.ts new file mode 100644 index 0000000..9dd975a --- /dev/null +++ b/Journal.App/src/lib/backend/speech.ts @@ -0,0 +1,33 @@ +import { invoke } from "$lib/runtime/invoke"; +import { + startRecording as startMicRecording, + stopRecording as stopMicRecording, +} from "tauri-plugin-mic-recorder-api"; + +type SpeechControlResult = { + running: boolean; + pid?: number; + launch?: string; +}; + +export async function startSpeechDictation(): Promise { + return invoke("speech_start"); +} + +export async function stopSpeechDictation(): Promise { + return invoke("speech_stop"); +} + +export async function probeMicrophoneAccess(): Promise { + await startMicRecording(); + await new Promise((resolve) => setTimeout(resolve, 300)); + const outputPath = await stopMicRecording(); + try { + await invoke<{ deleted: boolean }>("speech_cleanup_probe", { + path: outputPath, + }); + } catch { + // Keep probe non-blocking; cleanup failure should not break dictation start. + } + return outputPath; +} diff --git a/Journal.App/src/lib/backend/templates.ts b/Journal.App/src/lib/backend/templates.ts new file mode 100644 index 0000000..3f4f104 --- /dev/null +++ b/Journal.App/src/lib/backend/templates.ts @@ -0,0 +1,95 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +export type EntryTemplateItemDto = { + fileName: string; + filePath: string; +}; + +export type EntryTemplateLoadResultDto = { + fileName: string; + filePath: string; + content: string; +}; + +export type EntryTemplateSaveResultDto = { + filePath: string; +}; + +type EntryTemplateItemDtoRaw = { + fileName?: string; + filePath?: string; + FileName?: string; + FilePath?: string; +}; + +type EntryTemplateLoadResultDtoRaw = { + fileName?: string; + filePath?: string; + content?: string; + FileName?: string; + FilePath?: string; + Content?: string; +}; + +type EntryTemplateSaveResultDtoRaw = { + filePath?: string; + FilePath?: string; +}; + +function normalizeTemplateItem( + raw: EntryTemplateItemDtoRaw, +): EntryTemplateItemDto { + return { + fileName: pickCase(raw, "fileName", "FileName", ""), + filePath: pickCase(raw, "filePath", "FilePath", ""), + }; +} + +export async function listEntryTemplates(): Promise { + const data = await sendCommand({ + action: "templates.list", + payload: {}, + }); + + return data + .map(normalizeTemplateItem) + .filter((item) => Boolean(item.filePath)); +} + +export async function loadEntryTemplate( + filePath: string, +): Promise { + const data = await sendCommand({ + action: "templates.load", + payload: { filePath }, + }); + + return { + fileName: pickCase(data, "fileName", "FileName", ""), + filePath: pickCase(data, "filePath", "FilePath", ""), + content: pickCase(data, "content", "Content", ""), + }; +} + +export async function saveEntryTemplate(payload: { + name: string; + content: string; + filePath?: string; +}): Promise { + const data = await sendCommand({ + action: "templates.save", + payload, + }); + + return { + filePath: pickCase(data, "filePath", "FilePath", ""), + }; +} + +export async function deleteEntryTemplate(filePath: string): Promise { + return sendCommand({ + action: "templates.delete", + payload: { filePath }, + }); +} diff --git a/Journal.App/src/lib/backend/todos.ts b/Journal.App/src/lib/backend/todos.ts new file mode 100644 index 0000000..73c5150 --- /dev/null +++ b/Journal.App/src/lib/backend/todos.ts @@ -0,0 +1,154 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +export type TodoItemDto = { + id: string; + listId: string; + text: string; + done: boolean; + sortOrder: number; +}; + +export type TodoListDto = { + id: string; + label: string; + createdAt: string; + items: TodoItemDto[]; +}; + +export type CreateTodoListPayload = { + label: string; +}; + +export type UpdateTodoListPayload = { + label?: string; +}; + +export type CreateTodoItemPayload = { + listId: string; + text: string; + sortOrder?: number; +}; + +export type UpdateTodoItemPayload = { + text?: string; + done?: boolean; + sortOrder?: number; +}; + +type TodoItemDtoRaw = { + id?: string; + listId?: string; + text?: string; + done?: boolean; + sortOrder?: number; + Id?: string; + ListId?: string; + Text?: string; + Done?: boolean; + SortOrder?: number; +}; + +type TodoListDtoRaw = { + id?: string; + label?: string; + createdAt?: string; + items?: TodoItemDtoRaw[]; + Id?: string; + Label?: string; + CreatedAt?: string; + Items?: TodoItemDtoRaw[]; +}; + +function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto { + return { + id: pickCase(raw, "id", "Id", ""), + listId: pickCase(raw, "listId", "ListId", ""), + text: pickCase(raw, "text", "Text", ""), + done: pickCase(raw, "done", "Done", false), + sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0), + }; +} + +function normalizeList(raw: TodoListDtoRaw): TodoListDto { + const rawItems = pickCase(raw, "items", "Items", [] as TodoItemDtoRaw[]); + return { + id: pickCase(raw, "id", "Id", ""), + label: pickCase(raw, "label", "Label", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + items: rawItems.map(normalizeItem), + }; +} + +export async function listTodoLists(): Promise { + const data = await sendCommand({ + action: "todos.list", + }); + return data.map(normalizeList).filter((item) => Boolean(item.id)); +} + +export async function getTodoList(id: string): Promise { + const data = await sendCommand({ + action: "todos.get", + id, + }); + if (!data) return null; + const normalized = normalizeList(data); + return normalized.id ? normalized : null; +} + +export async function createTodoList( + payload: CreateTodoListPayload, +): Promise { + const data = await sendCommand({ + action: "todos.create", + payload, + }); + return normalizeList(data); +} + +export function updateTodoList( + id: string, + payload: UpdateTodoListPayload, +): Promise { + return sendCommand({ + action: "todos.update", + id, + payload, + }); +} + +export function deleteTodoList(id: string): Promise { + return sendCommand({ + action: "todos.delete", + id, + }); +} + +export async function createTodoItem( + payload: CreateTodoItemPayload, +): Promise { + const data = await sendCommand({ + action: "todos.items.create", + payload, + }); + return normalizeItem(data); +} + +export function updateTodoItem( + id: string, + payload: UpdateTodoItemPayload, +): Promise { + return sendCommand({ + action: "todos.items.update", + id, + payload, + }); +} + +export function deleteTodoItem(id: string): Promise { + return sendCommand({ + action: "todos.items.delete", + id, + }); +} diff --git a/Journal.App/src/lib/backend/types.ts b/Journal.App/src/lib/backend/types.ts new file mode 100644 index 0000000..1be56ad --- /dev/null +++ b/Journal.App/src/lib/backend/types.ts @@ -0,0 +1,12 @@ +export type BackendCommand = { + action: string; + correlationId?: string; + id?: string; + type?: string; + tag?: string; + payload?: unknown; +}; + +export type BackendOk = { ok: true; data: T }; +export type BackendErr = { ok: false; error: string }; +export type BackendResponse = BackendOk | BackendErr; diff --git a/Journal.App/src/lib/components/AppModal.svelte b/Journal.App/src/lib/components/AppModal.svelte new file mode 100644 index 0000000..240d830 --- /dev/null +++ b/Journal.App/src/lib/components/AppModal.svelte @@ -0,0 +1,165 @@ + + + + + +{#if open} + +{/if} + + diff --git a/Journal.App/src/lib/components/CalendarWidget.svelte b/Journal.App/src/lib/components/CalendarWidget.svelte new file mode 100644 index 0000000..756edfe --- /dev/null +++ b/Journal.App/src/lib/components/CalendarWidget.svelte @@ -0,0 +1,319 @@ + + + +
+
+ + +
+

{monthLabel}

+ {currentYear} +
+ + +
+ +
+ {#each weekdays as weekday} + {weekday} + {/each} +
+ +
+ {#each cells as cell} + + {/each} +
+
+ + diff --git a/Journal.App/src/lib/components/CoachPanel.svelte b/Journal.App/src/lib/components/CoachPanel.svelte new file mode 100644 index 0000000..67d551f --- /dev/null +++ b/Journal.App/src/lib/components/CoachPanel.svelte @@ -0,0 +1,706 @@ + + + +
+ +
+ {#if healthChecking} + + Checking AI status… + {:else if health} + + + {health.provider || "AI"} — {health.healthy ? "Ready" : "Unavailable"} + + {#if health.message} + {health.message} + {/if} + {:else} + + AI status unknown + {/if} +
+ + + {#if coachBusy} +
+
+

Running {kindLabels[coachKind] ?? "coach session"}…

+

+ This may take a moment while the model generates a response. +

+
+ {:else if coachError} +
+ error +

{coachError}

+
+ {:else if coachPlan} +
+
+ {kindLabels[coachPlan.kind] ?? coachPlan.kind} +

{coachPlan.title}

+
+ + {#if coachPlan.summary} +
+

Summary

+

{coachPlan.summary}

+
+ {/if} + + {#if coachPlan.questions.length > 0} +
+

Reflection Questions

+
    + {#each coachPlan.questions as question} +
  1. {question}
  2. + {/each} +
+
+ {/if} + + {#if coachPlan.suggestedNextActions.length > 0} +
+

Suggested Next Actions

+
    + {#each coachPlan.suggestedNextActions as action} +
  • {action}
  • + {/each} +
+
+ {/if} + + {#if coachPlan.suggestedTags.length > 0} +
+

Suggested Tags

+
+ {#each coachPlan.suggestedTags as tag} + {tag} + {/each} +
+
+ {/if} + + {#if coachPlan.evidence.length > 0} +
+

Evidence

+
    + {#each coachPlan.evidence as item} +
  • + {item.text} + {#if item.recordId} + ({item.recordId}) + {/if} +
  • + {/each} +
+
+ {/if} + + {#if coachPlan.patchProposal} +
+

Patch Proposal

+
+ {coachPlan.patchProposal.kind} + {#if coachPlan.patchProposal.description} +

{coachPlan.patchProposal.description}

+ {/if} + {#if coachPlan.patchProposal.content} +
{coachPlan.patchProposal.content}
+ {/if} +
+
+ {/if} +
+ {:else if chatMessages.length === 0 && !chatBusy} +
+ psychology +

+ Select a coaching session from the sidebar, or ask a question using the + input below. +

+
+ {/if} + + + {#if chatMessages.length > 0} +
+

Conversation

+
+ {#each chatMessages as msg} +
+ {msg.role === "user" + ? "You" + : msg.role === "error" + ? "Error" + : "AI"} +
{msg.text}
+
+ {/each} + {#if chatBusy} +
+ AI +
+
+ Thinking… +
+
+ {/if} +
+
+ {:else if chatBusy} +
+
+

Thinking…

+
+ {/if} + + +
+
+ + +
+ {#if chatMessages.length > 0} + + {/if} +
+
+ + diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte new file mode 100644 index 0000000..7f714b8 --- /dev/null +++ b/Journal.App/src/lib/components/EditorPanel.svelte @@ -0,0 +1,380 @@ + + + +
+ {#if showLinkedBackButton} +
+ +
+ {/if} + + {#if activeSection === "calendar"} +
+
+

Filtered Entries

+
+ {#if calendarBusy} +

Loading timeline...

+ {:else if calendarError} +

{calendarError}

+ {:else if calendarItems.length === 0} +

No entries matched the current filters.

+ {:else} +
    + {#each calendarCards as item} +
  • + +
  • + {/each} +
+ {/if} +
+ {:else if activeSection === "coach"} + + {:else if !openDocumentId} +
+ edit_note +

Select or create an item to get started

+
+ {:else if activeSection === "fragments"} + + {:else if activeSection === "todos"} + + {:else if activeSection === "lists"} + + {:else} + + {/if} +
+ + diff --git a/Journal.App/src/lib/components/Navbar.svelte b/Journal.App/src/lib/components/Navbar.svelte new file mode 100644 index 0000000..b33bf12 --- /dev/null +++ b/Journal.App/src/lib/components/Navbar.svelte @@ -0,0 +1,194 @@ + + + + + + diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte new file mode 100644 index 0000000..41378f3 --- /dev/null +++ b/Journal.App/src/lib/components/SidePanel.svelte @@ -0,0 +1,2002 @@ + + + +
+
+

{panelTitle}

+
+ {#if activeSection === "calendar"} + + {/if} + {#if activeSection === "entries"} + + + {/if} + {#if activeSection !== "coach"} + + {/if} +
+
+ + {#if isCalendarSection} + {#if showNewItemInput} +
+ +
+ {/if} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+

Saved Views

+
+ {#each builtInViews as view} + + {/each} + +
+ {#if showSaveViewInput} +
+ { + if (event.key === "Enter") saveCurrentView(); + if (event.key === "Escape") { + showSaveViewInput = false; + saveViewName = ""; + } + }} + on:blur={saveCurrentView} + /> +
+ {/if} + {#if calendarSavedViews.length > 0} +
    + {#each calendarSavedViews as view} +
  • + + +
  • + {/each} +
+ {/if} +
+
+ + + +
+

{calendarMonthLabel} {calendarYear} Timeline

+

+ Last refreshed: {calendarLastRefreshedAt || "Not yet"} +

+
+ {:else if activeSection === "coach"} +
+
+ +
+ +
+

Coaching Sessions

+
+ + + +
+ {#if $coachStateStore.plan} + + {/if} +
+ +
+
+

Conversations

+ +
+ + {#if $conversationsStore.busy} +

Loading…

+ {:else if $conversationsStore.items.length === 0} +

No conversations yet.

+ {:else} +
    + {#each $conversationsStore.items as conv} +
  • + {#if editingConversationId === conv.id} + + { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") cancelRename(); + }} + on:blur={commitRename} + autofocus + /> + {:else} + +
    + + +
    + {/if} +
  • + {/each} +
+ {/if} +
+
+ {:else} + + + {#if showNewItemInput} +
+ +
+ {/if} + + {#if activeSection === "entries"} +
+

Entries

+ {#if $entriesBusyStore} +

Loading entries...

+ {:else} +
    + {#each entryItems as item} +
  • + +
    + + +
    +
  • + {/each} +
+ {#if !entryItems.length} +

No entries found.

+ {/if} + {/if} +
+ +
+

Templates

+ {#if templatesBusy} +

Loading templates...

+ {:else} +
    + {#each allTemplateItems as item} +
  • + +
    + + +
    +
  • + {/each} +
+ {#if !allTemplateItems.length} +

No templates found.

+ {/if} + {/if} + {#if templateError} +

{templateError}

+ {/if} +
+ {:else} +
    + {#each items as item} +
  • + + {#if showItemActions} +
    + + +
    + {/if} +
  • + {/each} +
+ {/if} + {/if} +
+ + diff --git a/Journal.App/src/lib/components/editor/FragmentEditor.svelte b/Journal.App/src/lib/components/editor/FragmentEditor.svelte new file mode 100644 index 0000000..7987604 --- /dev/null +++ b/Journal.App/src/lib/components/editor/FragmentEditor.svelte @@ -0,0 +1,708 @@ + + + +
+ {#if dictationError || dictationStatus} +
+ {dictationError || dictationStatus} +
+ {/if} + {#if fragmentMode === "view"} +
+ {@html renderMarkdown(openDocumentContent)} +
+ {:else} +
+

{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}

+ +
+ + {#if fragmentType === customTypeValue} + + {:else} + + {/if} +
+
+ + +
+ +
+ + + +
+
+ {/if} +
+ + diff --git a/Journal.App/src/lib/components/editor/ListEditor.svelte b/Journal.App/src/lib/components/editor/ListEditor.svelte new file mode 100644 index 0000000..d7ea716 --- /dev/null +++ b/Journal.App/src/lib/components/editor/ListEditor.svelte @@ -0,0 +1,303 @@ + + + +
+
+
+ + +
+ +
    + {#each items as item} +
  • + {#if editingItemId === item.id} + { + if (event.key === "Enter") saveEditItem(); + if (event.key === "Escape") cancelEditItem(); + }} + /> +
    + + +
    + {:else} + {item.text} +
    + + +
    + {/if} +
  • + {/each} +
+
+
+ + diff --git a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte new file mode 100644 index 0000000..c173155 --- /dev/null +++ b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte @@ -0,0 +1,1134 @@ + + + +
+

{editorTitle}

+
+ +
+ {#if !previewOnly && (dictationError || dictationStatus)} +
+ {dictationError || dictationStatus} +
+ {/if} + {#if !previewOnly} + void applyTemplateByPath(filePath)} + onOpenAttachments={openAttachmentModal} + onBold={() => applyWrap("**")} + onItalic={() => applyWrap("*")} + onUnderline={() => applyWrap("++")} + onTag={insertTagToken} + onLink={insertLink} + onToggleUl={() => toggleListMode("ul")} + onToggleOl={() => toggleListMode("ol")} + onCode={() => applyWrap("`")} + onToggleDictation={() => void toggleDictation()} + {dictationActive} + {dictationBusy} + dictationUnavailable={!isTauriRuntime()} + onSave={() => void handleManualSave()} + saveBusy={isManualSaving} + /> + {#if templateError} +

{templateError}

+ {/if} + {#if manualSaveError} +

{manualSaveError}

+ {/if} + {/if} + + {#if attachmentModalOpen} + + {/if} + +
+ {#if previewOnly} +
+ {@html renderedHtml} +
+ {:else} + + {/if} +
+
+ + diff --git a/Journal.App/src/lib/components/editor/MarkdownToolbar.svelte b/Journal.App/src/lib/components/editor/MarkdownToolbar.svelte new file mode 100644 index 0000000..6424900 --- /dev/null +++ b/Journal.App/src/lib/components/editor/MarkdownToolbar.svelte @@ -0,0 +1,522 @@ + + + +
+
+
+ + + {#if headingMenuOpen} +
+ + + + + + +
+ {/if} +
+ {#if isEntryDocument} +
+ + + {#if templateMenuOpen} +
+ {#if templateOptions.length === 0} +
No templates
+ {:else} + {#each templateOptions as template} + + {/each} + {/if} +
+ {/if} +
+ + + {/if} +
+ +
+ + + + + + + + +
+ +
+ + +
+
+ + diff --git a/Journal.App/src/lib/components/editor/TodoEditor.svelte b/Journal.App/src/lib/components/editor/TodoEditor.svelte new file mode 100644 index 0000000..aa8fb84 --- /dev/null +++ b/Journal.App/src/lib/components/editor/TodoEditor.svelte @@ -0,0 +1,339 @@ + + + +
+
+
+ + +
+ +
    + {#each todoItems as todo} +
  • + + + {#if editingTodoId === todo.id} + { + if (event.key === "Enter") saveEditTodo(); + if (event.key === "Escape") cancelEditTodo(); + }} + /> +
    + + +
    + {:else} + {todo.text} +
    + + +
    + {/if} +
  • + {/each} +
+
+
+ + diff --git a/Journal.App/src/lib/runtime/invoke.ts b/Journal.App/src/lib/runtime/invoke.ts new file mode 100644 index 0000000..dbac2b1 --- /dev/null +++ b/Journal.App/src/lib/runtime/invoke.ts @@ -0,0 +1,184 @@ +import type { BackendCommand } from "$lib/backend/types"; + +type InvokeArgs = Record | undefined; + +type WindowWithTauri = Window & { + __TAURI_INTERNALS__?: unknown; +}; + +type UiSettingsPayload = { + tags?: string[]; + fragmentTypes?: string[]; + defaultStartupView?: string; +}; + +type FetchJsonOptions = { + keepalive?: boolean; +}; + +const UI_SETTINGS_KEY = "journal.ui.settings"; + +function normalizedApiBase(): string { + const configured = import.meta.env.VITE_JOURNAL_API_BASE?.trim(); + if (!configured) { + return "/api"; + } + + return configured.endsWith("/") ? configured.slice(0, -1) : configured; +} + +export function isTauriRuntime(): boolean { + if (typeof window === "undefined") { + return false; + } + + return Object.prototype.hasOwnProperty.call( + window as WindowWithTauri, + "__TAURI_INTERNALS__", + ); +} + +function readUiSettingsFromLocalStorage(): UiSettingsPayload { + if (typeof window === "undefined") { + return {}; + } + + const raw = window.localStorage.getItem(UI_SETTINGS_KEY); + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw) as UiSettingsPayload; + return { + tags: Array.isArray(parsed.tags) ? parsed.tags : undefined, + fragmentTypes: Array.isArray(parsed.fragmentTypes) + ? parsed.fragmentTypes + : undefined, + defaultStartupView: + typeof parsed.defaultStartupView === "string" + ? parsed.defaultStartupView + : undefined, + }; + } catch { + return {}; + } +} + +function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void { + if (typeof window === "undefined") { + return; + } + + const safePayload: UiSettingsPayload = { + tags: Array.isArray(payload.tags) ? payload.tags : undefined, + fragmentTypes: Array.isArray(payload.fragmentTypes) + ? payload.fragmentTypes + : undefined, + defaultStartupView: + typeof payload.defaultStartupView === "string" + ? payload.defaultStartupView + : undefined, + }; + + window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload)); +} + +async function fetchJson( + path: string, + init: RequestInit = {}, + options: FetchJsonOptions = {}, +): Promise { + const response = await fetch(`${normalizedApiBase()}${path}`, { + ...init, + keepalive: options.keepalive === true, + headers: { + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Request failed (${response.status})`); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +export async function invoke( + command: string, + args?: InvokeArgs, +): Promise { + if (isTauriRuntime()) { + const tauriCore = await import("@tauri-apps/api/core"); + return tauriCore.invoke(command, args); + } + + switch (command) { + case "sidecar_command": { + const envelope = args?.command; + if (!envelope || typeof envelope !== "object") { + throw new Error("Missing command payload."); + } + + const keepalive = args?.keepalive === true; + + return fetchJson( + "/command", + { + method: "POST", + body: JSON.stringify(envelope as BackendCommand), + }, + { keepalive }, + ); + } + case "get_sidecar_root": + return fetchJson("/sidecar/root"); + case "set_sidecar_root": { + const path = typeof args?.path === "string" ? args.path : ""; + return fetchJson("/sidecar/root", { + method: "POST", + body: JSON.stringify({ path }), + }); + } + case "get_ui_settings": + return readUiSettingsFromLocalStorage() as T; + case "set_ui_settings": { + const tags = Array.isArray(args?.tags) + ? (args?.tags as string[]) + : undefined; + const fragmentTypes = Array.isArray(args?.fragmentTypes) + ? (args?.fragmentTypes as string[]) + : Array.isArray(args?.fragment_types) + ? (args?.fragment_types as string[]) + : undefined; + const defaultStartupView = + typeof args?.defaultStartupView === "string" + ? args.defaultStartupView + : typeof args?.default_startup_view === "string" + ? args.default_startup_view + : undefined; + + writeUiSettingsToLocalStorage({ + tags, + fragmentTypes, + defaultStartupView, + }); + return undefined as T; + } + case "shutdown": + return undefined as T; + case "speech_start": + case "speech_stop": + throw new Error( + "Speech dictation is available in the desktop app runtime only.", + ); + default: + throw new Error(`Unsupported command in web runtime: ${command}`); + } +} diff --git a/Journal.App/src/lib/stores/ai.ts b/Journal.App/src/lib/stores/ai.ts new file mode 100644 index 0000000..a9571e7 --- /dev/null +++ b/Journal.App/src/lib/stores/ai.ts @@ -0,0 +1,140 @@ +import { writable } from "svelte/store"; +import { + aiHealth as aiHealthCommand, + aiChat as aiChatCommand, + coachDaily, + coachEvening, + coachWeekly, + type AiHealthDto, + type CoachPlanDto, + type CoachSessionPayload, +} from "$lib/backend/ai"; + +//#region Store Shapes +type AiStatusState = { + checking: boolean; + health: AiHealthDto | null; +}; + +type CoachState = { + busy: boolean; + error: string; + plan: CoachPlanDto | null; + kind: string; +}; + +export type ChatMessage = { + role: "user" | "assistant" | "error"; + text: string; +}; + +type ChatState = { + busy: boolean; + messages: ChatMessage[]; +}; +//#endregion + +//#region Stores +export const aiStatusStore = writable({ + checking: false, + health: null, +}); + +export const coachStateStore = writable({ + busy: false, + error: "", + plan: null, + kind: "", +}); + +export const chatStateStore = writable({ + busy: false, + messages: [], +}); +//#endregion + +//#region Actions +export async function checkAiHealth(): Promise { + aiStatusStore.update((s) => ({ ...s, checking: true })); + try { + const health = await aiHealthCommand(); + aiStatusStore.set({ checking: false, health }); + } catch (error) { + aiStatusStore.set({ + checking: false, + health: { + provider: "", + enabled: false, + healthy: false, + message: error instanceof Error ? error.message : String(error), + }, + }); + } +} + +const sessionRunners: Record< + string, + (payload: CoachSessionPayload) => Promise +> = { + daily: coachDaily, + evening: coachEvening, + weekly: coachWeekly, +}; + +export async function runCoachSession( + kind: string, + payload: CoachSessionPayload = {}, +): Promise { + const runner = sessionRunners[kind]; + if (!runner) { + coachStateStore.set({ + busy: false, + error: `Unknown coach session kind: ${kind}`, + plan: null, + kind, + }); + return; + } + + coachStateStore.set({ busy: true, error: "", plan: null, kind }); + try { + const plan = await runner(payload); + coachStateStore.set({ busy: false, error: "", plan, kind }); + } catch (error) { + coachStateStore.set({ + busy: false, + error: error instanceof Error ? error.message : String(error), + plan: null, + kind, + }); + } +} + +export async function runAiChat(prompt: string): Promise { + chatStateStore.update((s) => ({ + busy: true, + messages: [...s.messages, { role: "user", text: prompt }], + })); + try { + const response = await aiChatCommand(prompt); + chatStateStore.update((s) => ({ + busy: false, + messages: [...s.messages, { role: "assistant", text: response }], + })); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + chatStateStore.update((s) => ({ + busy: false, + messages: [...s.messages, { role: "error", text: msg }], + })); + } +} + +export function clearCoachPlan(): void { + coachStateStore.set({ busy: false, error: "", plan: null, kind: "" }); +} + +export function clearChat(): void { + chatStateStore.set({ busy: false, messages: [] }); +} +//#endregion diff --git a/Journal.App/src/lib/stores/conversations.ts b/Journal.App/src/lib/stores/conversations.ts new file mode 100644 index 0000000..4a7a85c --- /dev/null +++ b/Journal.App/src/lib/stores/conversations.ts @@ -0,0 +1,213 @@ +import { writable, get } from "svelte/store"; +import { + listConversations as listConversationsApi, + getConversation as getConversationApi, + createConversation as createConversationApi, + deleteConversation as deleteConversationApi, + updateConversation as updateConversationApi, + conversationChat as conversationChatApi, + type ConversationDto, + type ConversationMessageDto, +} from "$lib/backend/conversations"; + +//#region Store Shapes +type ConversationsState = { + items: ConversationDto[]; + busy: boolean; + error: string; +}; + +type ActiveConversationState = { + id: string; + title: string; + messages: ConversationMessageDto[]; + busy: boolean; + error: string; +}; +//#endregion + +//#region Stores +export const conversationsStore = writable({ + items: [], + busy: false, + error: "", +}); + +export const activeConversationStore = writable({ + id: "", + title: "", + messages: [], + busy: false, + error: "", +}); +//#endregion + +//#region Actions +export async function loadConversations(): Promise { + conversationsStore.update((s) => ({ ...s, busy: true, error: "" })); + try { + const items = await listConversationsApi(); + conversationsStore.set({ items, busy: false, error: "" }); + } catch (error) { + conversationsStore.update((s) => ({ + ...s, + busy: false, + error: error instanceof Error ? error.message : String(error), + })); + } +} + +export async function createNewConversation( + title: string, +): Promise { + try { + const conv = await createConversationApi(title); + conversationsStore.update((s) => ({ + ...s, + items: [conv, ...s.items], + })); + activeConversationStore.set({ + id: conv.id, + title: conv.title, + messages: [], + busy: false, + error: "", + }); + return conv.id; + } catch (error) { + conversationsStore.update((s) => ({ + ...s, + error: error instanceof Error ? error.message : String(error), + })); + return null; + } +} + +export async function openConversation(id: string): Promise { + activeConversationStore.update((s) => ({ + ...s, + id, + busy: true, + error: "", + })); + try { + const detail = await getConversationApi(id); + activeConversationStore.set({ + id: detail.id, + title: detail.title, + messages: detail.messages, + busy: false, + error: "", + }); + } catch (error) { + activeConversationStore.update((s) => ({ + ...s, + busy: false, + error: error instanceof Error ? error.message : String(error), + })); + } +} + +export async function sendConversationMessage(prompt: string): Promise { + const state = get(activeConversationStore); + if (!state.id) { + const title = prompt.length > 40 ? prompt.slice(0, 37) + "..." : prompt; + const convId = await createNewConversation(title); + if (!convId) return; + } + + const currentId = get(activeConversationStore).id; + if (!currentId) return; + + const tempUserMsg: ConversationMessageDto = { + id: `temp-${Date.now()}`, + role: "user", + text: prompt, + createdAt: new Date().toISOString(), + }; + + activeConversationStore.update((s) => ({ + ...s, + messages: [...s.messages, tempUserMsg], + busy: true, + error: "", + })); + + try { + const result = await conversationChatApi(currentId, prompt); + activeConversationStore.update((s) => ({ + ...s, + busy: false, + messages: [ + ...s.messages.filter((m) => m.id !== tempUserMsg.id), + result.userMessage, + result.assistantMessage, + ], + })); + void loadConversations(); + } catch (error) { + const errorMsg: ConversationMessageDto = { + id: `error-${Date.now()}`, + role: "error", + text: error instanceof Error ? error.message : String(error), + createdAt: new Date().toISOString(), + }; + activeConversationStore.update((s) => ({ + ...s, + busy: false, + messages: [...s.messages, errorMsg], + })); + } +} + +export async function renameConversation( + id: string, + title: string, +): Promise { + try { + await updateConversationApi(id, title); + conversationsStore.update((s) => ({ + ...s, + items: s.items.map((c) => (c.id === id ? { ...c, title } : c)), + })); + const active = get(activeConversationStore); + if (active.id === id) { + activeConversationStore.update((s) => ({ ...s, title })); + } + } catch (error) { + conversationsStore.update((s) => ({ + ...s, + error: error instanceof Error ? error.message : String(error), + })); + } +} + +export async function removeConversation(id: string): Promise { + try { + await deleteConversationApi(id); + conversationsStore.update((s) => ({ + ...s, + items: s.items.filter((c) => c.id !== id), + })); + const active = get(activeConversationStore); + if (active.id === id) { + clearActiveConversation(); + } + } catch (error) { + conversationsStore.update((s) => ({ + ...s, + error: error instanceof Error ? error.message : String(error), + })); + } +} + +export function clearActiveConversation(): void { + activeConversationStore.set({ + id: "", + title: "", + messages: [], + busy: false, + error: "", + }); +} +//#endregion diff --git a/Journal.App/src/lib/stores/entries.ts b/Journal.App/src/lib/stores/entries.ts new file mode 100644 index 0000000..d942a22 --- /dev/null +++ b/Journal.App/src/lib/stores/entries.ts @@ -0,0 +1,199 @@ +import { get, writable } from "svelte/store"; +import { + deleteEntry as deleteEntryCommand, + listEntries as listEntriesCommand, + loadEntry as loadEntryCommand, + saveEntry as saveEntryCommand, + searchEntries as searchEntriesCommand, + type EntryListItemDto, + type EntrySearchRequestDto, +} from "$lib/backend/entries"; + +export type EntryItem = { + id: string; + label: string; + initialContent: string; + filePath?: string; + date?: string; +}; + +const initialEntries: EntryItem[] = []; + +export const entriesStore = writable(initialEntries); +export const entriesBusyStore = writable(false); + +function toStoreId(filePath: string): string { + return `entries/file/${encodeURIComponent(filePath)}`; +} + +function toBackendPath(id: string): string | null { + const prefix = "entries/file/"; + if (!id.startsWith(prefix)) return null; + const encoded = id.slice(prefix.length).trim(); + if (!encoded) return null; + + try { + const decoded = decodeURIComponent(encoded); + return decoded || null; + } catch { + return null; + } +} + +function toLabel(fileName: string): string { + const normalized = fileName.trim(); + if (!normalized) return "Untitled Entry"; + return normalized.replace(/\.md$/i, ""); +} + +function upsertById(items: EntryItem[], next: EntryItem): EntryItem[] { + const idx = items.findIndex((item) => item.id === next.id); + if (idx === -1) return [next, ...items]; + const clone = [...items]; + clone[idx] = next; + return clone; +} + +function fromListDto(dto: EntryListItemDto): EntryItem { + return { + id: toStoreId(dto.filePath), + label: toLabel(dto.fileName), + initialContent: "", + filePath: dto.filePath, + }; +} + +function fromLoadResult( + result: Awaited>, +): EntryItem { + return { + id: toStoreId(result.filePath), + label: toLabel(result.fileName), + initialContent: result.entry.rawContent, + filePath: result.filePath, + date: result.entry.date, + }; +} + +export function getDefaultEntry(items: EntryItem[]): EntryItem | undefined { + return items[0]; +} + +export function createEntryDraft(): EntryItem { + const id = `entries/draft-${Date.now()}`; + return { + id, + label: "Untitled Entry", + initialContent: "# Untitled Entry\n\nStart writing...", + }; +} + +export async function hydrateEntries(): Promise { + entriesBusyStore.set(true); + try { + const items = await listEntriesCommand(); + const mapped = items.map(fromListDto); + entriesStore.set(mapped); + } catch (error) { + console.error("[entries] hydrate:error", error); + throw error; + } finally { + entriesBusyStore.set(false); + } +} + +export async function loadEntryByStoreId( + storeId: string, +): Promise { + const filePath = toBackendPath(storeId); + if (!filePath) return null; + + try { + const loaded = await loadEntryCommand(filePath); + const item = fromLoadResult(loaded); + entriesStore.update((items) => upsertById(items, item)); + return item; + } catch (error) { + console.error("[entries] load:error", { storeId, filePath, error }); + throw error; + } +} + +export async function saveEntryFromStore( + storeId: string, + content: string, + mode?: string, +): Promise { + const trimmed = content?.trim(); + if (!trimmed) return null; + + const existingPath = toBackendPath(storeId); + let payload: { + content: string; + filePath?: string; + mode?: string; + fileName?: string; + }; + + if (existingPath) { + payload = { content: trimmed, filePath: existingPath, mode }; + } else { + const draft = get(entriesStore).find((item) => item.id === storeId); + payload = { content: trimmed, mode, fileName: draft?.label }; + } + try { + const saved = await saveEntryCommand(payload); + const loaded = await loadEntryCommand(saved.filePath); + const item = fromLoadResult(loaded); + entriesStore.update((items) => { + const filtered = existingPath + ? items + : items.filter((i) => i.id !== storeId); + return upsertById(filtered, item); + }); + return item; + } catch (error) { + console.error("[entries] save:error", { storeId, error }); + throw error; + } +} + +export async function searchEntriesAsItems( + payload: EntrySearchRequestDto, +): Promise { + const results = await searchEntriesCommand(payload); + const toSearchPath = (fileName: string): string => + `db://entry/${encodeURIComponent(fileName)}`; + const mapped = results.map((result) => ({ + id: toStoreId(toSearchPath(result.fileName)), + label: toLabel(result.fileName), + initialContent: result.entry.rawContent, + filePath: toSearchPath(result.fileName), + date: result.entry.date, + })); + return mapped; +} + +export async function deleteEntryByStoreId(storeId: string): Promise { + if (storeId.startsWith("entries/draft-")) { + entriesStore.update((items) => items.filter((item) => item.id !== storeId)); + return true; + } + + const filePath = toBackendPath(storeId); + if (!filePath) return false; + + try { + const ok = await deleteEntryCommand(filePath); + if (!ok) return false; + entriesStore.update((items) => items.filter((item) => item.id !== storeId)); + return true; + } catch (error) { + console.error("[entries] delete:error", { storeId, error }); + return false; + } +} + +export function hasEntry(storeId: string): boolean { + return get(entriesStore).some((item) => item.id === storeId); +} diff --git a/Journal.App/src/lib/stores/fragments.ts b/Journal.App/src/lib/stores/fragments.ts new file mode 100644 index 0000000..96f8d2d --- /dev/null +++ b/Journal.App/src/lib/stores/fragments.ts @@ -0,0 +1,248 @@ +import { get, writable } from "svelte/store"; +import { + createFragment as createFragmentCommand, + deleteFragment as deleteFragmentCommand, + listFragments, + updateFragment as updateFragmentCommand, + type FragmentDto, +} from "$lib/backend/fragments"; + +export type FragmentItem = { + id: string; + label: string; + initialContent: string; +}; + +export type ParsedFragment = { + title: string; + type: string; + tags: string[]; + body: string; +}; + +const initialFragments: FragmentItem[] = []; + +export const fragmentsStore = writable(initialFragments); +export const fragmentsBusyStore = writable(false); + +function toStoreId(id: string): string { + return `fragments/${id}`; +} + +function toBackendId(id: string): string | null { + const prefix = "fragments/"; + if (!id.startsWith(prefix)) return null; + const backendId = id.slice(prefix.length).trim(); + return backendId || null; +} + +function splitDescription(description: string): { + title: string; + body: string; +} { + const normalized = description.trim(); + if (!normalized) { + return { title: "Untitled Fragment", body: "" }; + } + + const separator = normalized.indexOf("\n\n"); + if (separator === -1) { + return { title: normalized, body: "" }; + } + + const title = normalized.slice(0, separator).trim() || "Untitled Fragment"; + const body = normalized.slice(separator + 2).trim(); + return { title, body }; +} + +function composeDescription(title: string, body: string): string { + const resolvedTitle = title.trim() || "Untitled Fragment"; + const resolvedBody = body.trim() || "Add details for this fragment."; + return `${resolvedTitle}\n\n${resolvedBody}`; +} + +function dtoToItem(dto: FragmentDto): FragmentItem { + const parsed = splitDescription(dto.description); + return { + id: toStoreId(dto.id), + label: parsed.title, + initialContent: serializeFragment({ + title: parsed.title, + type: dto.type, + tags: dto.tags ?? [], + body: parsed.body, + }), + }; +} + +function upsertById(items: FragmentItem[], next: FragmentItem): FragmentItem[] { + const idx = items.findIndex((item) => item.id === next.id); + if (idx === -1) { + return [next, ...items]; + } + const clone = [...items]; + clone[idx] = next; + return clone; +} + +export function createFragmentId(title: string): string { + const slug = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + return `fragments/${slug || "fragment"}-${Date.now()}`; +} + +export function serializeFragment(payload: ParsedFragment): string { + const title = payload.title.trim() || "Untitled Fragment"; + const type = payload.type.trim(); + const tagsLine = payload.tags.length + ? payload.tags.map((tag) => `#${tag}`).join(" ") + : "(none)"; + const body = payload.body.trim() || "Add details for this fragment."; + return `# ${title}\n\nType: ${type}\n\nTags: ${tagsLine}\n\n${body}`; +} + +export function parseFragmentContent( + content: string, + fallbackTitle = "Untitled Fragment", +): ParsedFragment { + const headingMatch = content.match(/^#\s+(.+)$/m); + const typeMatch = content.match(/^Type:\s*(.+)$/m); + const tagsMatch = content.match(/^Tags:\s*(.+)$/m); + const bodyMatch = content.match(/^#.*\n\nType:.*\n\nTags:.*\n\n([\s\S]*)$/); + + const rawTags = tagsMatch?.[1]?.trim() ?? "(none)"; + const tags = + rawTags.toLowerCase() === "(none)" + ? [] + : rawTags + .split(/\s+/) + .map((tag) => tag.replace(/^#/, "").trim()) + .filter(Boolean); + + return { + title: headingMatch?.[1]?.trim() || fallbackTitle, + type: typeMatch?.[1]?.trim() || "", + tags, + body: bodyMatch?.[1]?.trim() || "", + }; +} + +export function createFragmentDraft(): FragmentItem { + const id = `fragments/new-${Date.now()}`; + return { + id, + label: "New Fragment", + initialContent: "# New Fragment\n\nType: \n\nTags: (none)\n\n", + }; +} + +export function createFragmentItem( + title: string, + content: string, +): FragmentItem { + return { + id: createFragmentId(title), + label: title.trim() || "Untitled Fragment", + initialContent: content, + }; +} + +export function updateFragmentItem( + items: FragmentItem[], + id: string, + title: string, + content: string, +): FragmentItem[] { + return items.map((item) => + item.id === id + ? { + ...item, + label: title.trim() || "Untitled Fragment", + initialContent: content, + } + : item, + ); +} + +export function prependFragmentItem( + items: FragmentItem[], + item: FragmentItem, +): FragmentItem[] { + return [item, ...items]; +} + +export function removeFragmentItem( + items: FragmentItem[], + id: string, +): FragmentItem[] { + return items.filter((item) => item.id !== id); +} + +export async function hydrateFragments(): Promise { + fragmentsBusyStore.set(true); + try { + const items = await listFragments(); + fragmentsStore.set(items.map(dtoToItem)); + } catch (error) { + console.error("[fragments] hydrate:error", error); + throw error; + } finally { + fragmentsBusyStore.set(false); + } +} + +export async function createFragmentFromParsed( + payload: ParsedFragment, +): Promise { + const created = await createFragmentCommand({ + type: payload.type.trim(), + description: composeDescription(payload.title, payload.body), + tags: payload.tags, + }); + const item = dtoToItem(created); + fragmentsStore.update((items) => prependFragmentItem(items, item)); + return item; +} + +export async function updateFragmentFromParsed( + storeId: string, + payload: ParsedFragment, +): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return null; + + const ok = await updateFragmentCommand(backendId, { + type: payload.type.trim(), + description: composeDescription(payload.title, payload.body), + tags: payload.tags, + }); + if (!ok) return null; + + const item: FragmentItem = { + id: storeId, + label: payload.title.trim() || "Untitled Fragment", + initialContent: serializeFragment(payload), + }; + fragmentsStore.update((items) => upsertById(items, item)); + return item; +} + +export async function deleteFragmentByStoreId( + storeId: string, +): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return false; + + const ok = await deleteFragmentCommand(backendId); + if (!ok) return false; + fragmentsStore.update((items) => removeFragmentItem(items, storeId)); + return true; +} + +export function hasFragment(storeId: string): boolean { + return get(fragmentsStore).some((item) => item.id === storeId); +} diff --git a/Journal.App/src/lib/stores/lists.ts b/Journal.App/src/lib/stores/lists.ts new file mode 100644 index 0000000..ce4c1e3 --- /dev/null +++ b/Journal.App/src/lib/stores/lists.ts @@ -0,0 +1,124 @@ +import { get, writable } from "svelte/store"; +import { + createList as createListCommand, + deleteList as deleteListCommand, + listLists, + updateList as updateListCommand, + type ListDocumentDto, +} from "$lib/backend/lists"; + +export type ListItem = { + id: string; + label: string; + initialContent: string; +}; + +export const listsStore = writable([]); +export const listsBusyStore = writable(false); + +function toStoreId(id: string): string { + return `lists/${id}`; +} + +function toBackendId(id: string): string | null { + const prefix = "lists/"; + if (!id.startsWith(prefix)) return null; + const backendId = id.slice(prefix.length).trim(); + return backendId || null; +} + +function dtoToItem(dto: ListDocumentDto): ListItem { + return { + id: toStoreId(dto.id), + label: dto.label, + initialContent: dto.content || `# ${dto.label}\n\n`, + }; +} + +function upsertById(items: ListItem[], next: ListItem): ListItem[] { + const idx = items.findIndex((item) => item.id === next.id); + if (idx === -1) return [next, ...items]; + const clone = [...items]; + clone[idx] = next; + return clone; +} + +export function createListDraft(): ListItem { + const id = `lists/draft-${Date.now()}`; + return { + id, + label: "Untitled List", + initialContent: "# Untitled List\n\n- Item 1", + }; +} + +export async function hydrateLists(): Promise { + listsBusyStore.set(true); + try { + const items = await listLists(); + listsStore.set(items.map(dtoToItem)); + } catch (error) { + console.error("[lists] hydrate:error", error); + throw error; + } finally { + listsBusyStore.set(false); + } +} + +export async function createListFromLabel( + label: string, + content = "", +): Promise { + const resolvedLabel = label.trim() || "Untitled List"; + const resolvedContent = content || `# ${resolvedLabel}\n\n`; + const created = await createListCommand({ + label: resolvedLabel, + content: resolvedContent, + }); + const item = dtoToItem(created); + listsStore.update((items) => [item, ...items]); + return item; +} + +export async function updateListByStoreId( + storeId: string, + label?: string, + content?: string, +): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return false; + + const payload: { label?: string; content?: string } = {}; + if (label !== undefined) payload.label = label; + if (content !== undefined) payload.content = content; + + const ok = await updateListCommand(backendId, payload); + if (!ok) return false; + + listsStore.update((items) => + items.map((item) => + item.id === storeId + ? { + ...item, + label: label ?? item.label, + initialContent: content ?? item.initialContent, + } + : item, + ), + ); + return true; +} + +export async function deleteListByStoreId(storeId: string): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return false; + + const ok = await deleteListCommand(backendId); + if (!ok) return false; + listsStore.update((items) => items.filter((item) => item.id !== storeId)); + return true; +} + +export function hasList(storeId: string): boolean { + return get(listsStore).some((item) => item.id === storeId); +} diff --git a/Journal.App/src/lib/stores/session.ts b/Journal.App/src/lib/stores/session.ts new file mode 100644 index 0000000..0d5c803 --- /dev/null +++ b/Journal.App/src/lib/stores/session.ts @@ -0,0 +1,34 @@ +import { writable, get } from "svelte/store"; + +const _password = writable(null); +const _unlocked = writable(false); + +export const vaultUnlocked = { subscribe: _unlocked.subscribe }; + +export function isVaultReady(): boolean { + return get(_unlocked); +} + +export function getSessionPassword(): string | null { + return get(_password); +} + +export function setVaultSession(password: string): void { + _password.set(password); + _unlocked.set(true); +} + +export function clearVaultSession(): void { + _password.set(null); + _unlocked.set(false); +} + +let _flushCallback: (() => Promise) | null = null; + +export function setFlushCallback(fn: () => Promise): void { + _flushCallback = fn; +} + +export async function flushBeforeClose(): Promise { + if (_flushCallback) await _flushCallback(); +} diff --git a/Journal.App/src/lib/stores/settings.ts b/Journal.App/src/lib/stores/settings.ts new file mode 100644 index 0000000..a96b9c7 --- /dev/null +++ b/Journal.App/src/lib/stores/settings.ts @@ -0,0 +1,184 @@ +import { writable } from "svelte/store"; +import { get } from "svelte/store"; +import { invoke } from "$lib/runtime/invoke"; + +const defaultTags = ["Personal", "Work", "Ideas", "Journal"]; +const defaultFragmentTypes = ["Quote", "Snippet", "Reference"]; +const startupViews = [ + "entries", + "calendar", + "fragments", + "todos", + "lists", + "coach", +] as const; +const defaultStartupView = "entries"; +export type StartupView = (typeof startupViews)[number]; + +export const settingsTags = writable([...defaultTags]); +export const settingsFragmentTypes = writable([ + ...defaultFragmentTypes, +]); +export const settingsDefaultStartupView = + writable(defaultStartupView); + +let hydrationComplete = false; +let hydrating = false; + +type UiSettingsPayload = { + tags?: string[]; + fragmentTypes?: string[]; + defaultStartupView?: string; +}; + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function hasDuplicate( + values: string[], + candidate: string, + excludeIndex?: number, +): boolean { + const normalized = normalize(candidate); + return values.some( + (value, index) => index !== excludeIndex && normalize(value) === normalized, + ); +} + +function normalizeValues(values: string[], fallback: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed) continue; + const key = normalize(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result.length ? result : [...fallback]; +} + +function normalizeStartupView(value: string | undefined): StartupView { + const normalized = (value ?? "").trim().toLowerCase(); + if (startupViews.includes(normalized as StartupView)) { + return normalized as StartupView; + } + return defaultStartupView; +} + +export async function hydrateUiSettings(): Promise { + if (hydrating || hydrationComplete) return; + hydrating = true; + try { + const payload = await invoke("get_ui_settings"); + settingsTags.set(normalizeValues(payload.tags ?? [], defaultTags)); + settingsFragmentTypes.set( + normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes), + ); + settingsDefaultStartupView.set( + normalizeStartupView(payload.defaultStartupView), + ); + } catch (error) { + console.error("[settings] hydrate failed", error); + } finally { + hydrating = false; + hydrationComplete = true; + } +} + +export async function persistUiSettings(): Promise { + const tags = normalizeValues(get(settingsTags), defaultTags); + const fragmentTypes = normalizeValues( + get(settingsFragmentTypes), + defaultFragmentTypes, + ); + const startupView = normalizeStartupView(get(settingsDefaultStartupView)); + settingsTags.set(tags); + settingsFragmentTypes.set(fragmentTypes); + settingsDefaultStartupView.set(startupView); + + await invoke("set_ui_settings", { + tags, + fragmentTypes, + fragment_types: fragmentTypes, + defaultStartupView: startupView, + default_startup_view: startupView, + }); +} + +function queuePersist(): void { + if (!hydrationComplete || hydrating) return; + void persistUiSettings().catch((error) => { + console.error("[settings] persist failed", error); + }); +} + +export function addSettingsTag(value: string): boolean { + const next = value.trim(); + if (!next) return false; + const tags = get(settingsTags); + if (hasDuplicate(tags, next)) return false; + settingsTags.set([...tags, next]); + queuePersist(); + return true; +} + +export function updateSettingsTag(index: number, value: string): boolean { + const next = value.trim(); + if (!next) return false; + const tags = get(settingsTags); + if (index < 0 || index >= tags.length) return false; + if (hasDuplicate(tags, next, index)) return false; + settingsTags.set(tags.map((tag, idx) => (idx === index ? next : tag))); + queuePersist(); + return true; +} + +export function removeSettingsTag(index: number): boolean { + const tags = get(settingsTags); + if (index < 0 || index >= tags.length) return false; + settingsTags.set(tags.filter((_, idx) => idx !== index)); + queuePersist(); + return true; +} + +export function addFragmentType(value: string): boolean { + const next = value.trim(); + if (!next) return false; + const types = get(settingsFragmentTypes); + if (hasDuplicate(types, next)) return false; + settingsFragmentTypes.set([...types, next]); + queuePersist(); + return true; +} + +export function updateFragmentType(index: number, value: string): boolean { + const next = value.trim(); + if (!next) return false; + const types = get(settingsFragmentTypes); + if (index < 0 || index >= types.length) return false; + if (hasDuplicate(types, next, index)) return false; + settingsFragmentTypes.set( + types.map((type, idx) => (idx === index ? next : type)), + ); + queuePersist(); + return true; +} + +export function removeFragmentType(index: number): boolean { + const types = get(settingsFragmentTypes); + if (index < 0 || index >= types.length) return false; + settingsFragmentTypes.set(types.filter((_, idx) => idx !== index)); + queuePersist(); + return true; +} + +export function setDefaultStartupView(value: string): boolean { + const next = normalizeStartupView(value); + if (get(settingsDefaultStartupView) === next) return false; + settingsDefaultStartupView.set(next); + queuePersist(); + return true; +} diff --git a/Journal.App/src/lib/stores/todos.ts b/Journal.App/src/lib/stores/todos.ts new file mode 100644 index 0000000..698d300 --- /dev/null +++ b/Journal.App/src/lib/stores/todos.ts @@ -0,0 +1,285 @@ +import { get, writable } from "svelte/store"; +import { + createTodoItem as createTodoItemCommand, + createTodoList as createTodoListCommand, + deleteTodoItem as deleteTodoItemCommand, + deleteTodoList as deleteTodoListCommand, + listTodoLists, + updateTodoItem as updateTodoItemCommand, + updateTodoList as updateTodoListCommand, + type TodoListDto, +} from "$lib/backend/todos"; + +export type TodoItem = { + id: number; + text: string; + done: boolean; + backendId?: string; +}; +export type TodoListMeta = { id: string; label: string; backendId?: string }; + +export const todoListsStore = writable([]); +export const todosStore = writable>({}); +export const todosBusyStore = writable(false); + +//#region ID Helpers +function toStoreId(guid: string): string { + return `todos/${guid}`; +} + +function toBackendId(storeId: string): string | null { + const prefix = "todos/"; + if (!storeId.startsWith(prefix)) return null; + const backendId = storeId.slice(prefix.length).trim(); + return backendId || null; +} + +export function createTodoId(): number { + return Date.now() + Math.floor(Math.random() * 1000); +} +//#endregion + +//#region DTO Mapping +function dtoToMeta(dto: TodoListDto): TodoListMeta { + return { + id: toStoreId(dto.id), + label: dto.label, + backendId: dto.id, + }; +} + +function dtoToItems(dto: TodoListDto): TodoItem[] { + return dto.items.map((item, index) => ({ + id: createTodoId() + index, + text: item.text, + done: item.done, + backendId: item.id, + })); +} +//#endregion + +//#region Hydration +export async function hydrateTodos(): Promise { + todosBusyStore.set(true); + try { + const lists = await listTodoLists(); + + const metas: TodoListMeta[] = lists.map(dtoToMeta); + const items: Record = {}; + for (const dto of lists) { + items[toStoreId(dto.id)] = dtoToItems(dto); + } + + todoListsStore.set(metas); + todosStore.set(items); + } catch (error) { + console.error("[todos] hydrate:error", error); + throw error; + } finally { + todosBusyStore.set(false); + } +} +//#endregion + +//#region List CRUD +export async function createTodoListFromLabel( + label: string, +): Promise<{ meta: TodoListMeta; items: TodoItem[] }> { + const resolvedLabel = label.trim() || "New List"; + const created = await createTodoListCommand({ label: resolvedLabel }); + + const meta = dtoToMeta(created); + todoListsStore.update((metas) => [meta, ...metas]); + todosStore.update((lists) => ({ ...lists, [meta.id]: [] })); + return { meta, items: [] }; +} + +export async function deleteTodoListByStoreId( + storeId: string, +): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return false; + + const ok = await deleteTodoListCommand(backendId); + if (!ok) return false; + + todoListsStore.update((metas) => metas.filter((m) => m.id !== storeId)); + todosStore.update((lists) => { + const { [storeId]: _, ...rest } = lists; + return rest; + }); + return true; +} +//#endregion + +//#region Item CRUD +export async function addTodoItemBackend( + storeId: string, + text: string, +): Promise { + const backendListId = toBackendId(storeId); + if (!backendListId || !text.trim()) return null; + + const items = get(todosStore)[storeId] ?? []; + const sortOrder = items.length; + + const created = await createTodoItemCommand({ + listId: backendListId, + text: text.trim(), + sortOrder, + }); + + const item: TodoItem = { + id: createTodoId(), + text: created.text, + done: created.done, + backendId: created.id, + }; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: [item, ...(lists[storeId] ?? [])], + })); + return item; +} + +export async function toggleTodoItemBackend( + storeId: string, + localId: number, +): Promise { + const items = get(todosStore)[storeId]; + const todo = items?.find((t) => t.id === localId); + if (!todo?.backendId) return false; + + const ok = await updateTodoItemCommand(todo.backendId, { done: !todo.done }); + if (!ok) return false; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: (lists[storeId] ?? []).map((t) => + t.id === localId ? { ...t, done: !t.done } : t, + ), + })); + return true; +} + +export async function updateTodoItemTextBackend( + storeId: string, + localId: number, + text: string, +): Promise { + const items = get(todosStore)[storeId]; + const todo = items?.find((t) => t.id === localId); + if (!todo?.backendId || !text.trim()) return false; + + const ok = await updateTodoItemCommand(todo.backendId, { text: text.trim() }); + if (!ok) return false; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: (lists[storeId] ?? []).map((t) => + t.id === localId ? { ...t, text: text.trim() } : t, + ), + })); + return true; +} + +export async function removeTodoItemBackend( + storeId: string, + localId: number, +): Promise { + const items = get(todosStore)[storeId]; + const todo = items?.find((t) => t.id === localId); + if (!todo?.backendId) return false; + + const ok = await deleteTodoItemCommand(todo.backendId); + if (!ok) return false; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId), + })); + return true; +} +//#endregion + +//#region Pure Helpers +export function serializeTodoList(title: string, todos: TodoItem[]): string { + const heading = title?.trim() ? `# ${title}` : "# To-Do List"; + const lines = todos.map( + (todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`, + ); + return `${heading}\n\n${lines.join("\n")}`; +} + +export function parseTodoList(content: string): TodoItem[] { + const lines = content.replace(/\r\n/g, "\n").split("\n"); + const parsed: TodoItem[] = []; + for (const line of lines) { + const match = line.match(/^- \[( |x)\]\s+(.+)$/i); + if (!match) continue; + parsed.push({ + id: createTodoId(), + text: match[2].trim(), + done: match[1].toLowerCase() === "x", + }); + } + return parsed; +} + +export function getOrCreateTodoList( + lists: Record, + documentId: string, + fallbackContent: string, +): { lists: Record; todos: TodoItem[] } { + const existing = lists[documentId]; + if (existing) { + return { lists, todos: existing }; + } + const parsed = parseTodoList(fallbackContent); + return { lists: { ...lists, [documentId]: parsed }, todos: parsed }; +} + +export function setTodoList( + lists: Record, + documentId: string, + todos: TodoItem[], +): Record { + return { ...lists, [documentId]: todos }; +} + +export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] { + return [{ id: createTodoId(), text: text.trim(), done: false }, ...todos]; +} + +export function toggleTodoItem(todos: TodoItem[], id: number): TodoItem[] { + return todos.map((todo) => + todo.id === id ? { ...todo, done: !todo.done } : todo, + ); +} + +export function updateTodoItemText( + todos: TodoItem[], + id: number, + text: string, +): TodoItem[] { + return todos.map((todo) => + todo.id === id ? { ...todo, text: text.trim() } : todo, + ); +} + +export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] { + return todos.filter((todo) => todo.id !== id); +} + +export function createTodoListDraft(): { + meta: TodoListMeta; + items: TodoItem[]; +} { + const id = `todos/draft-${Date.now()}`; + return { + meta: { id, label: "New List" }, + items: [], + }; +} +//#endregion diff --git a/Journal.App/src/lib/utils/markdown.ts b/Journal.App/src/lib/utils/markdown.ts new file mode 100644 index 0000000..7f329bc --- /dev/null +++ b/Journal.App/src/lib/utils/markdown.ts @@ -0,0 +1,150 @@ +export function escapeHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function parseInline(input: string): string { + let value = escapeHtml(input); + + value = value.replace(/\[\[([^[\]]+)\]\]/g, (match, rawGroup: string) => { + const tags = rawGroup + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + + if (!tags.length) return match; + + const chips = tags + .map((tag) => `${tag}`) + .join(""); + return `${chips}`; + }); + + value = value.replace( + /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)\b/g, + (_, leading: string, tag: string) => + `${leading}#${tag}`, + ); + + value = value.replace(/`([^`]+)`/g, "$1"); + value = value.replace(/\*\*([^*]+)\*\*/g, "$1"); + value = value.replace(/\+\+([^+]+)\+\+/g, "$1"); + value = value.replace(/\*([^*]+)\*/g, "$1"); + value = value.replace( + /\[([^\]]+)\]\((journal:[^\s)]+)\)/g, + '$1', + ); + value = value.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1', + ); + return value; +} + +export function renderMarkdown(markdown: string): string { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + let i = 0; + let inCode = false; + let codeLines: string[] = []; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed.startsWith("```")) { + if (inCode) { + output.push( + `
${escapeHtml(codeLines.join("\n"))}
`, + ); + codeLines = []; + inCode = false; + } else { + inCode = true; + } + i += 1; + continue; + } + + if (inCode) { + codeLines.push(line); + i += 1; + continue; + } + + if (!trimmed) { + i += 1; + continue; + } + + const heading = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + const level = heading[1].length; + output.push(`${parseInline(heading[2])}`); + i += 1; + continue; + } + + if (/^[-*+]\s+/.test(trimmed)) { + const items: string[] = []; + while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) { + items.push( + `
  • ${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}
  • `, + ); + i += 1; + } + output.push(`
      ${items.join("")}
    `); + continue; + } + + if (/^\d+\.\s+/.test(trimmed)) { + const items: string[] = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { + items.push( + `
  • ${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}
  • `, + ); + i += 1; + } + output.push(`
      ${items.join("")}
    `); + continue; + } + + if (/^>\s+/.test(trimmed)) { + output.push( + `
    ${parseInline(trimmed.replace(/^>\s+/, ""))}
    `, + ); + i += 1; + continue; + } + + if (/^(-{3,}|\*{3,})$/.test(trimmed)) { + output.push("
    "); + i += 1; + continue; + } + + const paragraph: string[] = []; + while (i < lines.length && lines[i].trim()) { + paragraph.push(lines[i].trim()); + i += 1; + } + output.push(`

    ${parseInline(paragraph.join(" "))}

    `); + } + + if (inCode) { + output.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + } + + return output.join(""); +} + +export function extractEditorTitle(markdown: string, fallback: string): string { + const firstLine = + markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? ""; + const headingMatch = firstLine.match(/^#\s+(.+)$/); + return headingMatch ? headingMatch[1] : fallback; +} diff --git a/Journal.App/src/lib/utils/metadata.ts b/Journal.App/src/lib/utils/metadata.ts new file mode 100644 index 0000000..6453eea --- /dev/null +++ b/Journal.App/src/lib/utils/metadata.ts @@ -0,0 +1,157 @@ +function normalizeTags(tags: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const tag of tags) { + const normalized = tag.trim(); + if (!normalized) continue; + const key = normalized.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(normalized); + } + return result; +} + +function splitFrontmatter(content: string): { + frontmatter: string | null; + body: string; +} { + const normalized = (content ?? "").replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) { + return { frontmatter: null, body: normalized }; + } + + const closingIndex = normalized.indexOf("\n---", 4); + if (closingIndex === -1) { + return { frontmatter: null, body: normalized }; + } + + const frontmatter = normalized.slice(4, closingIndex).trim(); + const bodyStart = closingIndex + "\n---".length; + const body = normalized.slice(bodyStart).replace(/^\n/, ""); + return { frontmatter, body }; +} + +function parseTagsValue(rawValue: string): string[] { + const value = rawValue.trim(); + if (!value) return []; + + if (value.startsWith("[") && value.endsWith("]")) { + return normalizeTags( + value + .slice(1, -1) + .split(",") + .map((token) => token.trim().replace(/^["']|["']$/g, "")), + ); + } + + return normalizeTags( + value.split(",").map((token) => token.trim().replace(/^["']|["']$/g, "")), + ); +} + +function formatTagsValue(tags: string[]): string { + const normalized = normalizeTags(tags); + if (!normalized.length) return ""; + return `[${normalized.join(", ")}]`; +} + +export function parseTagsFromMarkdown(content: string): string[] { + const { frontmatter } = splitFrontmatter(content); + if (!frontmatter) return []; + + const line = frontmatter + .split("\n") + .find((entry) => /^\s*tags\s*:/i.test(entry)); + if (!line) return []; + const value = line.replace(/^\s*tags\s*:/i, ""); + return parseTagsValue(value); +} + +export function stripFrontmatter(content: string): string { + return splitFrontmatter(content).body; +} + +export function setTagsInMarkdown(content: string, tags: string[]): string { + const normalizedBody = (content ?? "").replace(/\r\n/g, "\n"); + const normalizedTags = normalizeTags(tags); + const tagsLine = + normalizedTags.length > 0 ? `tags: ${formatTagsValue(normalizedTags)}` : ""; + const { frontmatter, body } = splitFrontmatter(normalizedBody); + + if (!frontmatter) { + if (!tagsLine) return normalizedBody; + const trimmedBody = body.replace(/^\n+/, ""); + return `---\n${tagsLine}\n---\n\n${trimmedBody}`; + } + + const lines = frontmatter + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => !/^\s*tags\s*:/i.test(line)); + + if (tagsLine) { + lines.unshift(tagsLine); + } + + if (lines.length === 0) { + return body; + } + + return `---\n${lines.join("\n")}\n---\n\n${body}`; +} + +export function extractBracketTags(content: string): string[] { + const normalized = (content ?? "").replace(/\r\n/g, "\n"); + const matches = normalized.matchAll(/\[\[([^\]]+)\]\]/g); + const tokens: string[] = []; + for (const match of matches) { + const raw = (match[1] ?? "").trim(); + if (!raw) continue; + tokens.push( + ...raw + .split(",") + .map((token) => token.trim()) + .filter(Boolean), + ); + } + return normalizeTags(tokens); +} + +export function extractTagsFromTagsSection(content: string): string[] { + const normalized = (content ?? "").replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + const collected: string[] = []; + + let inTagsSection = false; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!inTagsSection) { + if (/^#{1,6}\s+tags\s*$/i.test(line)) { + inTagsSection = true; + } + continue; + } + + if (!line) break; + if (/^#{1,6}\s+/.test(line)) break; + + const cleaned = line.replace(/^[-*+]\s+/, ""); + collected.push( + ...cleaned + .split(",") + .map((token) => token.trim()) + .filter(Boolean), + ); + } + + return normalizeTags(collected); +} + +export function extractEntryTags(content: string): string[] { + return normalizeTags([ + ...parseTagsFromMarkdown(content), + ...extractBracketTags(content), + ...extractTagsFromTagsSection(content), + ]); +} diff --git a/Journal.App/src/routes/+layout.svelte b/Journal.App/src/routes/+layout.svelte new file mode 100644 index 0000000..dd73cb0 --- /dev/null +++ b/Journal.App/src/routes/+layout.svelte @@ -0,0 +1,77 @@ + + + + diff --git a/Journal.App/src/routes/+layout.ts b/Journal.App/src/routes/+layout.ts new file mode 100644 index 0000000..9d24899 --- /dev/null +++ b/Journal.App/src/routes/+layout.ts @@ -0,0 +1,5 @@ +// Tauri doesn't have a Node.js server to do proper SSR +// so we use adapter-static with a fallback to index.html to put the site in SPA mode +// See: https://svelte.dev/docs/kit/single-page-apps +// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info +export const ssr = false; diff --git a/Journal.App/src/routes/+page.svelte b/Journal.App/src/routes/+page.svelte new file mode 100644 index 0000000..1b45696 --- /dev/null +++ b/Journal.App/src/routes/+page.svelte @@ -0,0 +1,744 @@ + + + +
    + + {#if panelOpen} + { + calendarPanelState = state; + }} + /> + {/if} + +
    + + + + diff --git a/Journal.App/src/routes/settings/+page.svelte b/Journal.App/src/routes/settings/+page.svelte new file mode 100644 index 0000000..ad86073 --- /dev/null +++ b/Journal.App/src/routes/settings/+page.svelte @@ -0,0 +1,799 @@ + + + +
    + + +
    +
    +
    +

    Settings

    +

    Configure app behavior and interface options.

    +
    + +
    + +
    +
    +
    +

    + + Startup +

    +
    + +
    + +
    +
    +

    + + Tags +

    +

    + Add and manage tags used for notes and entries. +

    +
    + +
    + event.key === "Enter" && addTag()} + /> + +
    + +
      + {#each $settingsTags as tag, index} +
    • + {#if editingTagIndex === index} + { + if (event.key === "Enter") saveEditTag(); + if (event.key === "Escape") cancelEditTag(); + }} + /> +
      + + +
      + {:else} + {tag} +
      + + +
      + {/if} +
    • + {/each} +
    +
    + +
    +
    +

    + + Fragment Types +

    +

    + Configure custom fragment types for the Fragments section. +

    +
    + +
    + + event.key === "Enter" && addFragmentTypeLocal()} + /> + +
    + +
      + {#each $settingsFragmentTypes as type, index} +
    • + {#if editingFragmentTypeIndex === index} + { + if (event.key === "Enter") saveEditFragmentType(); + if (event.key === "Escape") cancelEditFragmentType(); + }} + /> +
      + + +
      + {:else} + {type} +
      + + +
      + {/if} +
    • + {/each} +
    +
    + +
    +
    +

    + + Sidecar +

    +

    + Root directory containing the Journal.Sidecar project. +

    +
    + +
    + event.key === "Enter" && saveSidecarRoot()} + /> + + {#if sidecarRootIsCustom} + + {/if} +
    + + {#if sidecarRootError} +

    {sidecarRootError}

    + {/if} +
    +
    +
    +
    + + + + diff --git a/Journal.App/static/favicon.png b/Journal.App/static/favicon.png new file mode 100644 index 0000000..825b9e6 Binary files /dev/null and b/Journal.App/static/favicon.png differ diff --git a/Journal.App/static/icon.ico b/Journal.App/static/icon.ico new file mode 100644 index 0000000..00f68f0 Binary files /dev/null and b/Journal.App/static/icon.ico differ diff --git a/Journal.App/static/icon.png b/Journal.App/static/icon.png new file mode 100644 index 0000000..54cd806 Binary files /dev/null and b/Journal.App/static/icon.png differ diff --git a/Journal.App/static/icon.svg b/Journal.App/static/icon.svg new file mode 100644 index 0000000..a97ec5c --- /dev/null +++ b/Journal.App/static/icon.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/Journal.App/static/style.css b/Journal.App/static/style.css new file mode 100644 index 0000000..873272b --- /dev/null +++ b/Journal.App/static/style.css @@ -0,0 +1,116 @@ +:root { + font-family: + "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.45; + font-weight: 400; + + --zinc-50: #fafafa; + --zinc-100: #f4f4f5; + --zinc-200: #e4e4e7; + --zinc-300: #d4d4d8; + --zinc-400: #a1a1aa; + --zinc-500: #71717a; + --zinc-600: #52525b; + --zinc-700: #3f3f46; + --zinc-800: #27272a; + --zinc-900: #18181b; + --zinc-950: #09090b; + + --bg-app: var(--zinc-950); + --bg-navbar: var(--zinc-900); + --bg-panel: var(--zinc-800); + --bg-editor: var(--zinc-900); + --bg-hover: var(--zinc-800); + --bg-active: var(--zinc-700); + + --surface-1: var(--zinc-900); + --surface-2: var(--zinc-800); + --surface-3: var(--zinc-700); + + --border-soft: var(--zinc-700); + --border-strong: var(--zinc-600); + + --text-primary: var(--zinc-100); + --text-muted: var(--zinc-300); + --text-dim: var(--zinc-500); + --accent: var(--zinc-200); + + color: var(--text-primary); + background-color: var(--bg-app); + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +html, +body { + min-height: 100%; +} + +body { + background: radial-gradient( + circle at 15% -10%, + var(--zinc-800) 0%, + var(--bg-app) 42% + ); + color: var(--text-primary); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: inherit; +} + +button, +input, +select { + border: none; + outline: none; + background: none; + color: inherit; +} + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23d4d4d8' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 12px 12px; + padding-right: 30px; +} + +.app-shell { + min-height: 100vh; + min-height: 100dvh; + display: grid; + grid-template-columns: 72px 300px minmax(0, 1fr); +} + +.app-shell.panel-closed { + grid-template-columns: 72px minmax(0, 1fr); +} + +@media (max-width: 980px) { + .app-shell { + grid-template-columns: 64px minmax(0, 1fr); + grid-template-rows: 280px minmax(0, 1fr); + } + + .app-shell:not(.panel-closed) > .side-panel { + grid-column: 2; + grid-row: 1; + } + + .app-shell:not(.panel-closed) > .editor-panel { + grid-column: 2; + grid-row: 2; + } +} diff --git a/Journal.App/static/svelte.svg b/Journal.App/static/svelte.svg new file mode 100644 index 0000000..c5e0848 --- /dev/null +++ b/Journal.App/static/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Journal.App/static/tauri.svg b/Journal.App/static/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/Journal.App/static/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Journal.App/static/vite.svg b/Journal.App/static/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/Journal.App/static/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Journal.App/svelte.config.js b/Journal.App/svelte.config.js new file mode 100644 index 0000000..a7830ea --- /dev/null +++ b/Journal.App/svelte.config.js @@ -0,0 +1,18 @@ +// Tauri doesn't have a Node.js server to do proper SSR +// so we use adapter-static with a fallback to index.html to put the site in SPA mode +// See: https://svelte.dev/docs/kit/single-page-apps +// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + fallback: "index.html", + }), + }, +}; + +export default config; diff --git a/Journal.App/tsconfig.json b/Journal.App/tsconfig.json new file mode 100644 index 0000000..f4d0a0e --- /dev/null +++ b/Journal.App/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/Journal.App/vite.config.js b/Journal.App/vite.config.js new file mode 100644 index 0000000..3ecfa0a --- /dev/null +++ b/Journal.App/vite.config.js @@ -0,0 +1,32 @@ +import { defineConfig } from "vite"; +import { sveltekit } from "@sveltejs/kit/vite"; + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST; + +// https://vite.dev/config/ +export default defineConfig(async () => ({ + plugins: [sveltekit()], + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + }, +})); diff --git a/Journal.Core/Dtos/AiDtos.cs b/Journal.Core/Dtos/AiDtos.cs new file mode 100644 index 0000000..964498e --- /dev/null +++ b/Journal.Core/Dtos/AiDtos.cs @@ -0,0 +1,7 @@ +namespace Journal.Core.Dtos; + +public sealed record AiHealthDto( + string Provider, + bool Enabled, + bool Healthy, + string Message); diff --git a/Journal.Core/Dtos/CoachDtos.cs b/Journal.Core/Dtos/CoachDtos.cs new file mode 100644 index 0000000..7fd4235 --- /dev/null +++ b/Journal.Core/Dtos/CoachDtos.cs @@ -0,0 +1,32 @@ +namespace Journal.Core.Dtos; + +public sealed record CoachPlanDto( + string Kind, + string Title, + string Summary, + IReadOnlyList Questions, + IReadOnlyList SuggestedNextActions, + IReadOnlyList SuggestedTags, + IReadOnlyList Evidence, + CoachPatchProposalDto? PatchProposal = null); + +public sealed record CoachEvidenceDto( + string? RecordId, + string Text); + +public sealed record CoachPatchProposalDto( + string Kind, + string? Description = null, + string? Content = null); + +public sealed record CoachContextDto( + string DateLocal, + string? WeekStartLocal = null, + string? WeekEndLocal = null, + IReadOnlyList? RecentEntries = null, + IReadOnlyList? RecentFragments = null, + CoachPreferencesDto? Preferences = null); + +public sealed record CoachPreferencesDto( + int MaxQuestions = 3, + int MaxNextActions = 3); diff --git a/Journal.Core/Dtos/CommandDtos.cs b/Journal.Core/Dtos/CommandDtos.cs new file mode 100644 index 0000000..e979bfc --- /dev/null +++ b/Journal.Core/Dtos/CommandDtos.cs @@ -0,0 +1,47 @@ +namespace Journal.Core.Dtos; + +internal sealed record VaultInitializePayload(string Password, string VaultDirectory); +internal sealed record VaultPayload(string Password, string VaultDirectory); +internal sealed record ClearDataPayload(); +internal sealed record EntryListPayload(); +internal sealed record EntryLoadPayload(string FilePath); +public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null, string? FileName = null); +public sealed record EntryListItem(string FileName, string FilePath); +public sealed record EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry); +public sealed record EntrySaveResult(string FilePath); +internal sealed record EntryDeletePayload(string FilePath); +internal sealed record EntryTemplateListPayload(); +internal sealed record EntryTemplateLoadPayload(string FilePath); +internal sealed record EntryTemplateDeletePayload(string FilePath); +public sealed record EntryTemplateLoadResult(string FileName, string FilePath, string Content); +public sealed record EntryTemplateSavePayload(string Name, string Content, string? FilePath = null); +internal sealed record DatabasePayload(string Password); +internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null); +internal sealed record AiSummarizeAllPayload(List? Entries); +internal sealed record AiChatPayload(string Prompt); +internal sealed record AiEmbedPayload(string Content); +internal sealed record CoachDailyPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List? RecentEntries = null, List? RecentFragments = null); +internal sealed record CoachEveningPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List? RecentEntries = null, List? RecentFragments = null); +internal sealed record CoachWeeklyPayload(string? WeekStartLocal = null, string? WeekEndLocal = null, CoachPreferencesDto? Preferences = null, List? RecentEntries = null, List? RecentFragments = null); +internal sealed record SpeechTranscribePayload( + string? AudioBase64 = null, + string? Audio_Base64 = null, + string? Engine = null, + string? WhisperModel = null, + string? Whisper_Model = null, + string? Text = null, + int? SimulateDelayMs = null, + int? Simulate_Delay_Ms = null); +internal sealed record S2TPollPayload(int? MaxItems = null); +internal sealed record ConversationCreatePayload(string Title); +internal sealed record ConversationUpdatePayload(string? Title); +internal sealed record ConversationChatPayload(string ConversationId, string Prompt); +internal sealed record SearchEntriesPayload( + string? Query = null, + string? Section = null, + string? StartDate = null, + string? EndDate = null, + List? Tags = null, + List? Types = null, + List? Checked = null, + List? Unchecked = null); diff --git a/Journal.Core/Dtos/ConversationDtos.cs b/Journal.Core/Dtos/ConversationDtos.cs new file mode 100644 index 0000000..30b4aee --- /dev/null +++ b/Journal.Core/Dtos/ConversationDtos.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Journal.Core.Dtos; + +public sealed record ConversationDto( + Guid Id, + string Title, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record ConversationDetailDto( + Guid Id, + string Title, + IReadOnlyList Messages, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record ConversationMessageDto( + Guid Id, + string Role, + string Text, + DateTimeOffset CreatedAt); + +public sealed record CreateConversationDto( + [Required] string Title); + +public sealed record UpdateConversationDto( + string? Title); diff --git a/Journal.Core/Dtos/DatabaseDtos.cs b/Journal.Core/Dtos/DatabaseDtos.cs new file mode 100644 index 0000000..163b07e --- /dev/null +++ b/Journal.Core/Dtos/DatabaseDtos.cs @@ -0,0 +1,16 @@ +namespace Journal.Core.Dtos; + +public sealed record JournalDatabaseStatus( + string DatabasePath, + int KeyLengthBytes, + int Iterations, + string KeyDerivation, + IReadOnlyList SchemaTables, + bool RuntimeReady, + string RuntimeMessage); + +public sealed record JournalDatabaseHydrationResult( + string DatabasePath, + int EntryFilesProcessed, + bool RuntimeReady, + string Message); diff --git a/Journal.Core/Dtos/EntrySearchDtos.cs b/Journal.Core/Dtos/EntrySearchDtos.cs new file mode 100644 index 0000000..dbc8047 --- /dev/null +++ b/Journal.Core/Dtos/EntrySearchDtos.cs @@ -0,0 +1,15 @@ +namespace Journal.Core.Dtos; + +public sealed record EntrySearchRequestDto( + string? Query = null, + string? Section = null, + string? StartDate = null, + string? EndDate = null, + IReadOnlyList? Tags = null, + IReadOnlyList? Types = null, + IReadOnlyList? Checked = null, + IReadOnlyList? Unchecked = null); + +public sealed record EntrySearchResultDto( + string FileName, + JournalEntryDto Entry); diff --git a/Journal.Core/Dtos/FragmentDtos.cs b/Journal.Core/Dtos/FragmentDtos.cs new file mode 100644 index 0000000..aace939 --- /dev/null +++ b/Journal.Core/Dtos/FragmentDtos.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Journal.Core.Dtos; + +public record FragmentDto( + Guid Id, + string Type, + string Description, + DateTimeOffset Time, + List Tags +); + +public record CreateFragmentDto( + [property: Required(AllowEmptyStrings = false)] string Type, + [property: Required(AllowEmptyStrings = false)] string Description, + List? Tags = null +); + +public record UpdateFragmentDto( + string? Type = null, + string? Description = null, + List? Tags = null, + DateTimeOffset? Time = null +); diff --git a/Journal.Core/Dtos/JournalEntryDtos.cs b/Journal.Core/Dtos/JournalEntryDtos.cs new file mode 100644 index 0000000..4011700 --- /dev/null +++ b/Journal.Core/Dtos/JournalEntryDtos.cs @@ -0,0 +1,12 @@ +namespace Journal.Core.Dtos; + +public sealed record ParsedSectionDto( + string Title, + IReadOnlyList Content, + IReadOnlyDictionary Checkboxes); + +public sealed record JournalEntryDto( + string Date, + IReadOnlyList Fragments, + string RawContent, + IReadOnlyDictionary Sections); diff --git a/Journal.Core/Dtos/ListDtos.cs b/Journal.Core/Dtos/ListDtos.cs new file mode 100644 index 0000000..ef742bc --- /dev/null +++ b/Journal.Core/Dtos/ListDtos.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Journal.Core.Dtos; + +public record ListDocumentDto( + Guid Id, + string Label, + string Content, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt +); + +public record CreateListDto( + [property: Required(AllowEmptyStrings = false)] string Label, + string? Content = null +); + +public record UpdateListDto( + string? Label = null, + string? Content = null +); diff --git a/Journal.Core/Dtos/SpeechDtos.cs b/Journal.Core/Dtos/SpeechDtos.cs new file mode 100644 index 0000000..5c240e2 --- /dev/null +++ b/Journal.Core/Dtos/SpeechDtos.cs @@ -0,0 +1,37 @@ +namespace Journal.Core.Dtos; + +public sealed record SpeechDeviceDto( + int Index, + string Name); + +public sealed record SpeechDevicesResultDto( + IReadOnlyList Devices, + string? Warning = null); + +public sealed record SpeechTranscribeRequestDto( + string? AudioBase64 = null, + string? Engine = null, + string? WhisperModel = null, + string? Text = null, + int? SimulateDelayMs = null); + +public sealed record SpeechTranscribeResultDto( + string Text, + string Engine, + string? Warning = null); + +public sealed record S2TStartResultDto( + bool Running, + string Status, + string? Warning = null); + +public sealed record S2TStopResultDto( + bool Running, + string Status, + string? Warning = null); + +public sealed record S2TPollResultDto( + IReadOnlyList Items, + bool Running, + string Status, + string? Warning = null); diff --git a/Journal.Core/Dtos/TodoDtos.cs b/Journal.Core/Dtos/TodoDtos.cs new file mode 100644 index 0000000..c84a140 --- /dev/null +++ b/Journal.Core/Dtos/TodoDtos.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace Journal.Core.Dtos; + +public record TodoListDto( + Guid Id, + string Label, + DateTimeOffset CreatedAt, + List Items +); + +public record TodoItemDto( + Guid Id, + Guid ListId, + string Text, + bool Done, + int SortOrder +); + +public record CreateTodoListDto( + [property: Required(AllowEmptyStrings = false)] string Label +); + +public record UpdateTodoListDto( + string? Label = null +); + +public record CreateTodoItemDto( + [property: Required] Guid ListId, + [property: Required(AllowEmptyStrings = false)] string Text, + int? SortOrder = null +); + +public record UpdateTodoItemDto( + string? Text = null, + bool? Done = null, + int? SortOrder = null +); diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs new file mode 100644 index 0000000..bfc425d --- /dev/null +++ b/Journal.Core/Entry.cs @@ -0,0 +1,575 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Services.Ai; +using Journal.Core.Services.Config; +using Journal.Core.Services.Database; +using Journal.Core.Services.Entries; +using Journal.Core.Services.Fragments; +using Journal.Core.Services.Lists; +using Journal.Core.Services.Conversations; +using Journal.Core.Services.Logging; +using Journal.Core.Services.Speech; +using Journal.Core.Services.Todos; +using Journal.Core.Services.Vault; + +namespace Journal.Core; + +public class Entry( + IFragmentService fragments, + IEntrySearchService entrySearch, + IVaultStorageService vaultStorage, + IJournalDatabaseService database, + IDatabaseSessionService databaseSession, + IJournalConfigService config, + IAiService ai, + ISpeechBridgeService speech, + IS2TService liveSpeech, + IEntryFileService entryFiles, + IListService lists, + ITodoService todos, + ICoachService coach, + IConversationService conversations, + CommandLogger logger) +{ + private readonly IFragmentService _fragments = fragments; + private readonly IEntrySearchService _entrySearch = entrySearch; + private readonly IVaultStorageService _vaultStorage = vaultStorage; + private readonly IJournalDatabaseService _database = database; + private readonly IDatabaseSessionService _databaseSession = databaseSession; + private readonly IJournalConfigService _config = config; + private readonly IAiService _ai = ai; + private readonly ISpeechBridgeService _speech = speech; + private readonly IS2TService _liveSpeech = liveSpeech; + private readonly IEntryFileService _entryFiles = entryFiles; + private readonly IListService _lists = lists; + private readonly ITodoService _todos = todos; + private readonly ICoachService _coach = coach; + private readonly IConversationService _conversations = conversations; + private readonly CommandLogger _logger = logger; + private static readonly HashSet VaultSyncActions = new(StringComparer.Ordinal) + { + "entries.save", + "entries.delete", + "templates.save", + "templates.delete", + "fragments.create", + "fragments.update", + "fragments.delete", + "lists.create", + "lists.update", + "lists.delete", + "todos.create", + "todos.update", + "todos.delete", + "todos.items.create", + "todos.items.update", + "todos.items.delete", + "conversations.create", + "conversations.update", + "conversations.delete", + "conversations.chat" + }; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public async Task RunAsync() + { + string? line; + while ((line = Console.ReadLine()) is not null) + { + var response = await HandleCommandAsync(line); + Console.WriteLine(response); + } + } + + public async Task HandleCommandAsync(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return Error("Invalid command"); + + Command? cmd; + try + { + cmd = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return Error("Invalid command JSON"); + } + + if (cmd is null || string.IsNullOrWhiteSpace(cmd.Action)) + return Error("Invalid command"); + + var action = cmd.Action.Trim(); + var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId) + ? Guid.NewGuid().ToString("N") + : cmd.CorrelationId.Trim(); + CommandLogger.LogStart(action, correlationId, cmd.Payload); + object? result; + + try + { + switch (action) + { + case "fragments.list": + result = _fragments.GetAll(); + break; + case "fragments.get": + if (!Guid.TryParse(cmd.Id, out var getId)) + return Error("Invalid or missing id"); + result = _fragments.GetById(getId); + break; + case "fragments.create": + var createDto = DeserializePayload(cmd.Payload); + if (createDto is null) + return Error("Missing or invalid payload"); + result = _fragments.Create(createDto); + break; + case "fragments.update": + if (!Guid.TryParse(cmd.Id, out var updateId)) + return Error("Invalid or missing id"); + var updateDto = DeserializePayload(cmd.Payload); + if (updateDto is null) + return Error("Missing or invalid payload"); + result = _fragments.Update(updateId, updateDto); + break; + case "fragments.delete": + if (!Guid.TryParse(cmd.Id, out var deleteId)) + return Error("Invalid or missing id"); + result = _fragments.Remove(deleteId); + break; + case "fragments.search": + result = _fragments.Search(cmd.Type, cmd.Tag); + break; + + // ── Lists ──────────────────────────────────────── + case "lists.list": + result = _lists.GetAll(); + break; + case "lists.get": + if (!Guid.TryParse(cmd.Id, out var getListId)) + return Error("Invalid or missing id"); + result = _lists.GetById(getListId); + break; + case "lists.create": + var createListDto = DeserializePayload(cmd.Payload); + if (createListDto is null) + return Error("Missing or invalid payload"); + result = _lists.Create(createListDto); + break; + case "lists.update": + if (!Guid.TryParse(cmd.Id, out var updateListId)) + return Error("Invalid or missing id"); + var updateListDto = DeserializePayload(cmd.Payload); + if (updateListDto is null) + return Error("Missing or invalid payload"); + result = _lists.Update(updateListId, updateListDto); + break; + case "lists.delete": + if (!Guid.TryParse(cmd.Id, out var deleteListId)) + return Error("Invalid or missing id"); + result = _lists.Remove(deleteListId); + break; + + // ── Todos ──────────────────────────────────────── + case "todos.list": + result = _todos.GetAllLists(); + break; + case "todos.get": + if (!Guid.TryParse(cmd.Id, out var getTodoListId)) + return Error("Invalid or missing id"); + result = _todos.GetListById(getTodoListId); + break; + case "todos.create": + var createTodoListDto = DeserializePayload(cmd.Payload); + if (createTodoListDto is null) + return Error("Missing or invalid payload"); + result = _todos.CreateList(createTodoListDto); + break; + case "todos.update": + if (!Guid.TryParse(cmd.Id, out var updateTodoListId)) + return Error("Invalid or missing id"); + var updateTodoListDto = DeserializePayload(cmd.Payload); + if (updateTodoListDto is null) + return Error("Missing or invalid payload"); + result = _todos.UpdateList(updateTodoListId, updateTodoListDto); + break; + case "todos.delete": + if (!Guid.TryParse(cmd.Id, out var deleteTodoListId)) + return Error("Invalid or missing id"); + result = _todos.RemoveList(deleteTodoListId); + break; + case "todos.items.create": + var createItemDto = DeserializePayload(cmd.Payload); + if (createItemDto is null) + return Error("Missing or invalid payload"); + result = _todos.CreateItem(createItemDto); + break; + case "todos.items.update": + if (!Guid.TryParse(cmd.Id, out var updateItemId)) + return Error("Invalid or missing id"); + var updateItemDto = DeserializePayload(cmd.Payload); + if (updateItemDto is null) + return Error("Missing or invalid payload"); + result = _todos.UpdateItem(updateItemId, updateItemDto); + break; + case "todos.items.delete": + if (!Guid.TryParse(cmd.Id, out var deleteItemId)) + return Error("Invalid or missing id"); + result = _todos.RemoveItem(deleteItemId); + break; + case "search.entries": + var searchPayload = DeserializePayload(cmd.Payload); + if (searchPayload is null) + return Error("Missing or invalid payload"); + var searchRequest = new EntrySearchRequestDto( + Query: searchPayload.Query, + Section: searchPayload.Section, + StartDate: searchPayload.StartDate, + EndDate: searchPayload.EndDate, + Tags: searchPayload.Tags, + Types: searchPayload.Types, + Checked: searchPayload.Checked, + Unchecked: searchPayload.Unchecked); + result = await _entrySearch.SearchEntriesAsync(searchRequest); + break; + case "entries.list": + _ = DeserializePayload(cmd.Payload); + result = _entryFiles.ListEntries(); + break; + case "templates.list": + _ = DeserializePayload(cmd.Payload); + result = _entryFiles.ListTemplates(); + break; + case "entries.load": + var loadEntryPayload = DeserializePayload(cmd.Payload); + if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath)) + return Error("Missing or invalid payload"); + result = _entryFiles.LoadEntry(loadEntryPayload.FilePath); + break; + case "templates.load": + var loadTemplatePayload = DeserializePayload(cmd.Payload); + if (loadTemplatePayload is null || string.IsNullOrWhiteSpace(loadTemplatePayload.FilePath)) + return Error("Missing or invalid payload"); + result = _entryFiles.LoadTemplate(loadTemplatePayload.FilePath); + break; + case "entries.save": + var saveEntryPayload = DeserializePayload(cmd.Payload); + if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content)) + return Error("Missing or invalid payload"); + result = _entryFiles.SaveEntry(saveEntryPayload); + break; + case "templates.save": + var saveTemplatePayload = DeserializePayload(cmd.Payload); + if (saveTemplatePayload is null || string.IsNullOrWhiteSpace(saveTemplatePayload.Name)) + return Error("Missing or invalid payload"); + result = _entryFiles.SaveTemplate(saveTemplatePayload); + break; + case "entries.delete": + var deleteEntryPayload = DeserializePayload(cmd.Payload); + if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath)) + return Error("Missing or invalid payload"); + result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath); + break; + case "templates.delete": + var deleteTemplatePayload = DeserializePayload(cmd.Payload); + if (deleteTemplatePayload is null || string.IsNullOrWhiteSpace(deleteTemplatePayload.FilePath)) + return Error("Missing or invalid payload"); + result = _entryFiles.DeleteTemplate(deleteTemplatePayload.FilePath); + break; + case "config.get": + result = _config.Current; + break; + case "ai.health": + result = await _ai.HealthAsync(); + break; + case "ai.summarize_entry": + var summarizeEntryPayload = DeserializePayload(cmd.Payload); + if (summarizeEntryPayload is null || string.IsNullOrWhiteSpace(summarizeEntryPayload.Content)) + return Error("Missing or invalid payload"); + result = await _ai.SummarizeEntryAsync(summarizeEntryPayload.Content, summarizeEntryPayload.FileStem); + break; + case "ai.summarize_all": + var summarizeAllPayload = DeserializePayload(cmd.Payload); + if (summarizeAllPayload is null) + return Error("Missing or invalid payload"); + result = await _ai.SummarizeAllAsync(summarizeAllPayload.Entries ?? []); + break; + case "ai.chat": + var chatPayload = DeserializePayload(cmd.Payload); + if (chatPayload is null || string.IsNullOrWhiteSpace(chatPayload.Prompt)) + return Error("Missing or invalid payload"); + result = await _ai.ChatAsync(chatPayload.Prompt); + break; + case "ai.embed": + var embedPayload = DeserializePayload(cmd.Payload); + if (embedPayload is null || string.IsNullOrWhiteSpace(embedPayload.Content)) + return Error("Missing or invalid payload"); + result = await _ai.EmbedAsync(embedPayload.Content); + break; + + // ── Coach ───────────────────────────────────────── + case "ai.coach.daily": + var coachDailyPayload = DeserializePayload(cmd.Payload); + result = await _coach.DailyCheckInAsync(new CoachContextDto( + DateLocal: coachDailyPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"), + RecentEntries: coachDailyPayload?.RecentEntries, + RecentFragments: coachDailyPayload?.RecentFragments, + Preferences: coachDailyPayload?.Preferences)); + break; + case "ai.coach.evening": + var coachEveningPayload = DeserializePayload(cmd.Payload); + result = await _coach.EveningReviewAsync(new CoachContextDto( + DateLocal: coachEveningPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"), + RecentEntries: coachEveningPayload?.RecentEntries, + RecentFragments: coachEveningPayload?.RecentFragments, + Preferences: coachEveningPayload?.Preferences)); + break; + case "ai.coach.weekly": + var coachWeeklyPayload = DeserializePayload(cmd.Payload); + var now = DateTime.Now; + var weekStart = now.AddDays(-(int)now.DayOfWeek + (int)DayOfWeek.Monday); + result = await _coach.WeeklyReviewAsync(new CoachContextDto( + DateLocal: now.ToString("yyyy-MM-dd"), + WeekStartLocal: coachWeeklyPayload?.WeekStartLocal ?? weekStart.ToString("yyyy-MM-dd"), + WeekEndLocal: coachWeeklyPayload?.WeekEndLocal ?? weekStart.AddDays(6).ToString("yyyy-MM-dd"), + RecentEntries: coachWeeklyPayload?.RecentEntries, + RecentFragments: coachWeeklyPayload?.RecentFragments, + Preferences: coachWeeklyPayload?.Preferences)); + break; + + // ── Conversations ────────────────────────────────── + case "conversations.list": + result = _conversations.GetAll(); + break; + case "conversations.get": + if (!Guid.TryParse(cmd.Id, out var getConvId)) + return Error("Invalid or missing id"); + result = _conversations.GetById(getConvId); + break; + case "conversations.create": + var convCreatePayload = DeserializePayload(cmd.Payload); + if (convCreatePayload is null || string.IsNullOrWhiteSpace(convCreatePayload.Title)) + return Error("Missing or invalid payload"); + result = _conversations.Create(new CreateConversationDto(convCreatePayload.Title)); + break; + case "conversations.update": + if (!Guid.TryParse(cmd.Id, out var updateConvId)) + return Error("Invalid or missing id"); + var convUpdatePayload = DeserializePayload(cmd.Payload); + if (convUpdatePayload is null) + return Error("Missing or invalid payload"); + result = _conversations.Update(updateConvId, new UpdateConversationDto(convUpdatePayload.Title)); + break; + case "conversations.delete": + if (!Guid.TryParse(cmd.Id, out var deleteConvId)) + return Error("Invalid or missing id"); + result = _conversations.Remove(deleteConvId); + break; + case "conversations.chat": + var convChatPayload = DeserializePayload(cmd.Payload); + if (convChatPayload is null || string.IsNullOrWhiteSpace(convChatPayload.Prompt) + || !Guid.TryParse(convChatPayload.ConversationId, out var chatConvId)) + return Error("Missing or invalid payload"); + // Save user message + var userMsg = _conversations.AddMessage(chatConvId, "user", convChatPayload.Prompt); + // Build history from existing messages + var history = _conversations.GetMessages(chatConvId) + .Where(m => m.Id != userMsg.Id) + .Select(m => (m.Role, m.Text)) + .ToList(); + // Get AI response with full conversation context + var aiResponse = await _ai.ChatWithHistoryAsync(history, convChatPayload.Prompt); + // Save AI response + var assistantMsg = _conversations.AddMessage(chatConvId, "assistant", aiResponse); + result = new { userMessage = userMsg, assistantMessage = assistantMsg }; + break; + + case "speech.devices.list": + result = await _speech.ListDevicesAsync(); + break; + case "speech.transcribe": + var speechPayload = DeserializePayload(cmd.Payload); + if (speechPayload is null) + return Error("Missing or invalid payload"); + var audioBase64 = !string.IsNullOrWhiteSpace(speechPayload.AudioBase64) + ? speechPayload.AudioBase64 + : speechPayload.Audio_Base64; + var text = speechPayload.Text; + var whisperModel = !string.IsNullOrWhiteSpace(speechPayload.WhisperModel) + ? speechPayload.WhisperModel + : speechPayload.Whisper_Model; + var simulateDelayMs = speechPayload.SimulateDelayMs ?? speechPayload.Simulate_Delay_Ms; + if (string.IsNullOrWhiteSpace(audioBase64) && string.IsNullOrWhiteSpace(text)) + return Error("Missing or invalid payload"); + result = await _speech.TranscribeAsync(new SpeechTranscribeRequestDto( + AudioBase64: audioBase64, + Engine: speechPayload.Engine, + WhisperModel: whisperModel, + Text: text, + SimulateDelayMs: simulateDelayMs)); + break; + case "speech.live.start": + result = await _liveSpeech.StartAsync(); + break; + case "speech.live.stop": + result = await _liveSpeech.StopAsync(); + break; + case "speech.live.poll": + var livePollPayload = DeserializePayload(cmd.Payload); + var maxItems = livePollPayload?.MaxItems ?? 8; + if (maxItems <= 0) + maxItems = 1; + if (maxItems > 64) + maxItems = 64; + result = await _liveSpeech.PollAsync(maxItems); + break; + case "vault.initialize": + var initPayload = DeserializePayload(cmd.Payload); + if (initPayload is null || string.IsNullOrWhiteSpace(initPayload.Password) || string.IsNullOrWhiteSpace(initPayload.VaultDirectory)) + return Error("Missing or invalid payload"); + Directory.CreateDirectory(initPayload.VaultDirectory); + result = true; + break; + case "vault.load_all": + var loadPayload = DeserializePayload(cmd.Payload); + if (loadPayload is null) + return Error("Missing or invalid payload"); + var vaultStorageDirectory = ResolveVaultStorageDirectory(); + var loaded = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, vaultStorageDirectory); + if (loaded) + _databaseSession.SetPassword(loadPayload.Password); + result = loaded; + break; + case "vault.rebuild_all": + var rebuildPayload = DeserializePayload(cmd.Payload); + if (rebuildPayload is null) + return Error("Missing or invalid payload"); + _databaseSession.CloseConnection(); + _vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, ResolveVaultStorageDirectory()); + result = true; + break; + case "vault.clear_data_directory": + var clearPayload = DeserializePayload(cmd.Payload); + if (clearPayload is null) + return Error("Missing or invalid payload"); + if (_databaseSession is IDisposable disposableSession) + disposableSession.Dispose(); + _vaultStorage.ClearDataDirectory(ResolveVaultStorageDirectory()); + result = true; + break; + case "db.status": + var dbStatusPayload = DeserializePayload(cmd.Payload); + if (dbStatusPayload is null || string.IsNullOrWhiteSpace(dbStatusPayload.Password)) + return Error("Missing or invalid payload"); + result = _database.GetStatus(dbStatusPayload.Password); + break; + case "db.initialize_schema": + var dbInitPayload = DeserializePayload(cmd.Payload); + if (dbInitPayload is null || string.IsNullOrWhiteSpace(dbInitPayload.Password)) + return Error("Missing or invalid payload"); + var initResult = _database.HydrateWorkspace(dbInitPayload.Password); + result = new + { + initialized = initResult.RuntimeReady, + databasePath = initResult.DatabasePath, + initResult.Message + }; + break; + case "db.hydrate_workspace": + var dbHydratePayload = DeserializePayload(cmd.Payload); + if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password)) + return Error("Missing or invalid payload"); + result = _database.HydrateWorkspace(dbHydratePayload.Password); + _databaseSession.SetPassword(dbHydratePayload.Password); + break; + default: + CommandLogger.LogFailure(action, correlationId, "unknown_action"); + return Error($"Unknown action: {action}"); + } + } + catch (JsonException) + { + CommandLogger.LogFailure(action, correlationId, "invalid_payload_json"); + return Error("Missing or invalid payload"); + } + catch (ValidationException ex) + { + CommandLogger.LogFailure(action, correlationId, "validation", ex.Message); + return Error(ex.Message); + } + catch (ArgumentException ex) + { + CommandLogger.LogFailure(action, correlationId, "argument", ex.Message); + return Error(ex.Message); + } + catch (TimeoutException ex) + { + CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message); + return Error(ex.Message); + } + catch (InvalidOperationException ex) + { + CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message); + return Error(ex.Message); + } + catch (FileNotFoundException ex) + { + CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message); + return Error(ex.Message); + } + catch + { + CommandLogger.LogFailure(action, correlationId, "internal_error"); + return Error("Internal error"); + } + + TryAutoSyncVault(action, correlationId); + CommandLogger.LogSuccess(action, correlationId); + return JsonSerializer.Serialize(new { ok = true, data = result }); + } + + private static string Error(string message) + => JsonSerializer.Serialize(new { ok = false, error = message }); + + private static T? DeserializePayload(JsonElement? payload) + { + if (payload is null) + return default; + return payload.Value.Deserialize(JsonOptions); + } + + private void TryAutoSyncVault(string action, string correlationId) + { + if (!VaultSyncActions.Contains(action)) + return; + + if (!_databaseSession.TryGetSession(out var password)) + return; + + try + { + var config = _config.Current; + _databaseSession.CloseConnection(); + _vaultStorage.RebuildAllVaults(password, config.VaultDirectory, ResolveVaultStorageDirectory()); + } + catch (Exception ex) + { + CommandLogger.LogFailure(action, correlationId, "vault_auto_sync_failed", ex.Message); + } + } + + private string ResolveVaultStorageDirectory() + { + var dbPath = _database.GetDatabasePath(); + var directory = Path.GetDirectoryName(dbPath); + return string.IsNullOrWhiteSpace(directory) + ? Path.GetFullPath(".") + : Path.GetFullPath(directory); + } +} diff --git a/Journal.Core/Journal.Core.csproj b/Journal.Core/Journal.Core.csproj new file mode 100644 index 0000000..60a9a1f --- /dev/null +++ b/Journal.Core/Journal.Core.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Journal.Core/Models/Command.cs b/Journal.Core/Models/Command.cs new file mode 100644 index 0000000..ac44027 --- /dev/null +++ b/Journal.Core/Models/Command.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace Journal.Core.Models; + +public class Command +{ + public string Action { get; set; } = ""; + public string? CorrelationId { get; set; } + public string? Id { get; set; } + public string? Type { get; set; } + public string? Tag { get; set; } + public JsonElement? Payload { get; set; } +} diff --git a/Journal.Core/Models/Conversation.cs b/Journal.Core/Models/Conversation.cs new file mode 100644 index 0000000..bd3f652 --- /dev/null +++ b/Journal.Core/Models/Conversation.cs @@ -0,0 +1,33 @@ +namespace Journal.Core.Models; + +public class Conversation +{ + public Guid Id { get; } + public string Title { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public Conversation(string title) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Title is required", nameof(title)); + + Id = Guid.NewGuid(); + Title = title.Trim(); + CreatedAt = DateTimeOffset.Now; + UpdatedAt = CreatedAt; + } + + public Conversation(Guid id, string title, DateTimeOffset createdAt, DateTimeOffset updatedAt) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Title is required", nameof(title)); + + Id = id; + Title = title.Trim(); + CreatedAt = createdAt; + UpdatedAt = updatedAt; + } +} diff --git a/Journal.Core/Models/ConversationMessage.cs b/Journal.Core/Models/ConversationMessage.cs new file mode 100644 index 0000000..36c6bba --- /dev/null +++ b/Journal.Core/Models/ConversationMessage.cs @@ -0,0 +1,38 @@ +namespace Journal.Core.Models; + +public class ConversationMessage +{ + public Guid Id { get; } + public Guid ConversationId { get; } + public string Role { get; set; } + public string Text { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public ConversationMessage(Guid conversationId, string role, string text) + { + if (conversationId == Guid.Empty) + throw new ArgumentException("ConversationId is required", nameof(conversationId)); + if (string.IsNullOrWhiteSpace(role)) + throw new ArgumentException("Role is required", nameof(role)); + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text is required", nameof(text)); + + Id = Guid.NewGuid(); + ConversationId = conversationId; + Role = role.Trim(); + Text = text; + CreatedAt = DateTimeOffset.Now; + } + + public ConversationMessage(Guid id, Guid conversationId, string role, string text, DateTimeOffset createdAt) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + + Id = id; + ConversationId = conversationId; + Role = role; + Text = text; + CreatedAt = createdAt; + } +} diff --git a/Journal.Core/Models/Fragment.cs b/Journal.Core/Models/Fragment.cs new file mode 100644 index 0000000..6b67db7 --- /dev/null +++ b/Journal.Core/Models/Fragment.cs @@ -0,0 +1,42 @@ +namespace Journal.Core.Models; + +public class Fragment +{ + public Guid Id { get; } + public string Type { get; set; } + public string Description { get; set; } + public DateTimeOffset Time { get; set; } + public List Tags { get; set; } = []; + + public Fragment(string type, string description) + { + Validate(type, description); + + Id = Guid.NewGuid(); + Type = type.Trim(); + Description = description.Trim(); + Time = DateTimeOffset.Now; + } + + public Fragment(Guid id, string type, string description, DateTimeOffset time, IEnumerable? tags = null) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + Validate(type, description); + + Id = id; + Type = type.Trim(); + Description = description.Trim(); + Time = time; + if (tags is not null) + Tags = [.. tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim())]; + } + + private static void Validate(string type, string description) + { + if (string.IsNullOrWhiteSpace(type)) + throw new ArgumentException("Type is required", nameof(type)); + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description is required", nameof(description)); + } +} diff --git a/Journal.Core/Models/JournalConfig.cs b/Journal.Core/Models/JournalConfig.cs new file mode 100644 index 0000000..0108f92 --- /dev/null +++ b/Journal.Core/Models/JournalConfig.cs @@ -0,0 +1,25 @@ +namespace Journal.Core.Models; + +public sealed record JournalConfig( + string ProjectRoot, + string AppDirectory, + string VaultDirectory, + string LogDirectory, + string PidFile, + string ServerControlFile, + string DatabaseFilename, + string CloudAiApiKey, + string CloudAiApiUrl, + string LlamaCppUrl, + string LlamaCppModel, + int LlamaCppTimeout, + string EmbeddingApiUrl, + string EmbeddingModelName, + int ModelContextTokens, + int ChunkTokenBudget, + int? MicrophoneDeviceIndex, + string SpeechRecognitionEngine, + string WhisperModelSize, + string NlpBackend, + string AiProvider, + string GgufModelPath); diff --git a/Journal.Core/Models/JournalEntry.cs b/Journal.Core/Models/JournalEntry.cs new file mode 100644 index 0000000..e647c55 --- /dev/null +++ b/Journal.Core/Models/JournalEntry.cs @@ -0,0 +1,98 @@ +namespace Journal.Core.Models; + +public class JournalEntry +{ + public string Date { get; set; } + public List Fragments { get; set; } + public string RawContent { get; set; } + public Dictionary Sections { get; set; } + + public JournalEntry( + string date, + IEnumerable? fragments = null, + string rawContent = "", + IDictionary? sections = null) + { + if (string.IsNullOrWhiteSpace(date)) + throw new ArgumentException("Date is required", nameof(date)); + + Date = date.Trim(); + Fragments = fragments is null ? [] : [.. fragments]; + RawContent = rawContent ?? ""; + Sections = sections is null ? [] : new Dictionary(sections); + } + + public string GetSection(string sectionTitle) + { + if (string.IsNullOrWhiteSpace(sectionTitle)) + return ""; + if (!Sections.TryGetValue(sectionTitle, out var section)) + return ""; + return string.Join("\n", section.Content); + } + + public bool? GetCheckboxState(string sectionTitle, string checkboxText) + { + if (string.IsNullOrWhiteSpace(sectionTitle) || string.IsNullOrWhiteSpace(checkboxText)) + return null; + if (!Sections.TryGetValue(sectionTitle, out var section)) + return null; + return section.Checkboxes.TryGetValue(checkboxText, out var checkedState) ? checkedState : null; + } + + public void MergeWith(JournalEntry otherEntry) + { + ArgumentNullException.ThrowIfNull(otherEntry); + + foreach (var (title, newSection) in otherEntry.Sections) + { + if (newSection.Content.Any(line => !string.IsNullOrWhiteSpace(line))) + Sections[title] = newSection; + } + + var existingFragmentDescriptions = Fragments + .Select(fragment => fragment.Description) + .ToHashSet(StringComparer.Ordinal); + + foreach (var newFragment in otherEntry.Fragments) + { + if (!existingFragmentDescriptions.Contains(newFragment.Description)) + Fragments.Add(newFragment); + } + } + + public string ToMarkdown() + { + var lines = new List + { + "---", + "type: journal", + "---", + $"**Date:** {Date}\n" + }; + + foreach (var title in SectionTitles.Canonical) + { + if (!Sections.TryGetValue(title, out var section)) + continue; + + lines.Add($"## {section.Title}\n"); + lines.AddRange(section.Content); + lines.Add(""); + } + + if (Fragments.Count > 0) + { + lines.Add("# Fragments\n"); + foreach (var fragment in Fragments) + { + var timeStr = fragment.Time != default ? $"@{fragment.Time:O}" : ""; + var tagsStr = string.Join(" ", fragment.Tags.Select(tag => $"#{tag}")); + var header = $"{fragment.Type} {timeStr} {tagsStr}".Trim(); + lines.Add($"{header}\n{fragment.Description}\n"); + } + } + + return string.Join("\n", lines); + } +} diff --git a/Journal.Core/Models/ListDocument.cs b/Journal.Core/Models/ListDocument.cs new file mode 100644 index 0000000..c61a9c7 --- /dev/null +++ b/Journal.Core/Models/ListDocument.cs @@ -0,0 +1,40 @@ +namespace Journal.Core.Models; + +public class ListDocument +{ + public Guid Id { get; } + public string Label { get; set; } + public string Content { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public ListDocument(string label, string content = "") + { + Validate(label); + + Id = Guid.NewGuid(); + Label = label.Trim(); + Content = content; + CreatedAt = DateTimeOffset.Now; + UpdatedAt = CreatedAt; + } + + public ListDocument(Guid id, string label, string content, DateTimeOffset createdAt, DateTimeOffset updatedAt) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + Validate(label); + + Id = id; + Label = label.Trim(); + Content = content; + CreatedAt = createdAt; + UpdatedAt = updatedAt; + } + + private static void Validate(string label) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label is required", nameof(label)); + } +} diff --git a/Journal.Core/Models/ParsedSection.cs b/Journal.Core/Models/ParsedSection.cs new file mode 100644 index 0000000..bc9ff3c --- /dev/null +++ b/Journal.Core/Models/ParsedSection.cs @@ -0,0 +1,21 @@ +namespace Journal.Core.Models; + +public class ParsedSection +{ + public string Title { get; set; } + public List Content { get; set; } + public Dictionary Checkboxes { get; set; } + + public ParsedSection( + string title, + IEnumerable? content = null, + IDictionary? checkboxes = null) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Section title is required", nameof(title)); + + Title = title.Trim(); + Content = content is null ? [] : [.. content]; + Checkboxes = checkboxes is null ? [] : new Dictionary(checkboxes); + } +} diff --git a/Journal.Core/Models/SectionTitles.cs b/Journal.Core/Models/SectionTitles.cs new file mode 100644 index 0000000..3aaf666 --- /dev/null +++ b/Journal.Core/Models/SectionTitles.cs @@ -0,0 +1,20 @@ +namespace Journal.Core.Models; + +public static class SectionTitles +{ + public static readonly IReadOnlyList Canonical = + [ + "Summary", + "Cognitive State", + "Mental / Emotional Snapshot", + "Memory / Mind Failures", + "Events / Triggers", + "Communication / Expression Log", + "Coping / Tools Used", + "Reflection", + "Core Events or Memories", + "Autism/ADHD-Related Elements", + "Emotional & Bodily Reactions", + "Truth to Anchor Myself To", + ]; +} diff --git a/Journal.Core/Models/TodoItem.cs b/Journal.Core/Models/TodoItem.cs new file mode 100644 index 0000000..f225aa0 --- /dev/null +++ b/Journal.Core/Models/TodoItem.cs @@ -0,0 +1,44 @@ +namespace Journal.Core.Models; + +public class TodoItem +{ + public Guid Id { get; } + public Guid ListId { get; } + public string Text { get; set; } + public bool Done { get; set; } + public int SortOrder { get; set; } + + public TodoItem(Guid listId, string text, int sortOrder = 0) + { + Validate(text); + if (listId == Guid.Empty) + throw new ArgumentException("ListId is required", nameof(listId)); + + Id = Guid.NewGuid(); + ListId = listId; + Text = text.Trim(); + Done = false; + SortOrder = sortOrder; + } + + public TodoItem(Guid id, Guid listId, string text, bool done, int sortOrder) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + if (listId == Guid.Empty) + throw new ArgumentException("ListId is required", nameof(listId)); + Validate(text); + + Id = id; + ListId = listId; + Text = text.Trim(); + Done = done; + SortOrder = sortOrder; + } + + private static void Validate(string text) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text is required", nameof(text)); + } +} diff --git a/Journal.Core/Models/TodoList.cs b/Journal.Core/Models/TodoList.cs new file mode 100644 index 0000000..6fa533e --- /dev/null +++ b/Journal.Core/Models/TodoList.cs @@ -0,0 +1,34 @@ +namespace Journal.Core.Models; + +public class TodoList +{ + public Guid Id { get; } + public string Label { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public TodoList(string label) + { + Validate(label); + + Id = Guid.NewGuid(); + Label = label.Trim(); + CreatedAt = DateTimeOffset.Now; + } + + public TodoList(Guid id, string label, DateTimeOffset createdAt) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + Validate(label); + + Id = id; + Label = label.Trim(); + CreatedAt = createdAt; + } + + private static void Validate(string label) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label is required", nameof(label)); + } +} diff --git a/Journal.Core/Repositories/IConversationRepository.cs b/Journal.Core/Repositories/IConversationRepository.cs new file mode 100644 index 0000000..271ecb9 --- /dev/null +++ b/Journal.Core/Repositories/IConversationRepository.cs @@ -0,0 +1,14 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public interface IConversationRepository +{ + List GetAll(); + Conversation? GetById(Guid id); + void Add(Conversation conversation); + bool Update(Guid id, string? title = null); + bool Remove(Guid id); + void AddMessage(ConversationMessage message); + List GetMessages(Guid conversationId); +} diff --git a/Journal.Core/Repositories/IEntryFileRepository.cs b/Journal.Core/Repositories/IEntryFileRepository.cs new file mode 100644 index 0000000..6057cea --- /dev/null +++ b/Journal.Core/Repositories/IEntryFileRepository.cs @@ -0,0 +1,15 @@ +namespace Journal.Core.Repositories; + +public interface IEntryFileRepository +{ + IReadOnlyList ListMarkdownFiles(); + string ReadFile(string filePath); + void WriteFile(string filePath, string content); + void AppendFile(string filePath, string content); + bool FileExists(string filePath); + string GetFullPath(string filePath); + string GetFileName(string filePath); + string GetFileNameWithoutExtension(string filePath); + void EnsureDirectory(string path); + void DeleteFile(string filePath); +} diff --git a/Journal.Core/Repositories/IFragmentRepository.cs b/Journal.Core/Repositories/IFragmentRepository.cs new file mode 100644 index 0000000..bb05f0d --- /dev/null +++ b/Journal.Core/Repositories/IFragmentRepository.cs @@ -0,0 +1,15 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public interface IFragmentRepository +{ + List GetAll(); + Fragment? GetById(Guid id); + void Add(Fragment fragment); + bool Remove(Guid id); + bool Update(Guid id, string? type = null, string? description = null, IEnumerable? tags = null, DateTimeOffset? time = null); + List GetByTag(string tag); + List GetByType(string type); + List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); +} diff --git a/Journal.Core/Repositories/IListRepository.cs b/Journal.Core/Repositories/IListRepository.cs new file mode 100644 index 0000000..5191443 --- /dev/null +++ b/Journal.Core/Repositories/IListRepository.cs @@ -0,0 +1,12 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public interface IListRepository +{ + List GetAll(); + ListDocument? GetById(Guid id); + void Add(ListDocument list); + bool Update(Guid id, string? label = null, string? content = null); + bool Remove(Guid id); +} diff --git a/Journal.Core/Repositories/ITodoRepository.cs b/Journal.Core/Repositories/ITodoRepository.cs new file mode 100644 index 0000000..87fcb58 --- /dev/null +++ b/Journal.Core/Repositories/ITodoRepository.cs @@ -0,0 +1,18 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public interface ITodoRepository +{ + List GetAllLists(); + TodoList? GetListById(Guid id); + void AddList(TodoList list); + bool UpdateList(Guid id, string? label = null); + bool RemoveList(Guid id); + + List GetItemsByListId(Guid listId); + TodoItem? GetItemById(Guid id); + void AddItem(TodoItem item); + bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null); + bool RemoveItem(Guid id); +} diff --git a/Journal.Core/Repositories/SqliteConversationRepository.cs b/Journal.Core/Repositories/SqliteConversationRepository.cs new file mode 100644 index 0000000..ab85675 --- /dev/null +++ b/Journal.Core/Repositories/SqliteConversationRepository.cs @@ -0,0 +1,217 @@ +using Journal.Core.Models; +using Journal.Core.Services.Database; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Repositories; + +public sealed class SqliteConversationRepository(IDatabaseSessionService session) : IConversationRepository +{ + private readonly IDatabaseSessionService _session = session; + + public List GetAll() + { + var conn = _session.GetConnection(); + return ReadAll(conn); + } + + public Conversation? GetById(Guid id) + { + var conn = _session.GetConnection(); + return ReadById(conn, id); + } + + public void Add(Conversation conversation) + { + ArgumentNullException.ThrowIfNull(conversation); + var conn = _session.GetConnection(); + Insert(conn, conversation); + } + + public bool Update(Guid id, string? title = null) + { + var conn = _session.GetConnection(); + var existing = ReadById(conn, id); + if (existing is null) + return false; + + if (title is not null) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Title cannot be empty", nameof(title)); + existing.Title = title.Trim(); + } + + existing.UpdatedAt = DateTimeOffset.Now; + UpdateRow(conn, existing); + return true; + } + + public bool Remove(Guid id) + { + var conn = _session.GetConnection(); + return Delete(conn, id); + } + + public void AddMessage(ConversationMessage message) + { + ArgumentNullException.ThrowIfNull(message); + var conn = _session.GetConnection(); + InsertMessage(conn, message); + + // Touch conversation updated_at + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE conversations SET updated_at = @now WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@now", DateTimeOffset.Now.ToString("O")); + cmd.Parameters.AddWithValue("@guid", message.ConversationId.ToString("D")); + cmd.ExecuteNonQuery(); + } + + public List GetMessages(Guid conversationId) + { + var conn = _session.GetConnection(); + return ReadMessages(conn, conversationId); + } + + // ── Private helpers ────────────────────────────────────────────── + + private static void Insert(SqliteConnection conn, Conversation c) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO conversations (guid, title, created_at, updated_at) + VALUES (@guid, @title, @createdAt, @updatedAt); + """; + cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D")); + cmd.Parameters.AddWithValue("@title", c.Title); + cmd.Parameters.AddWithValue("@createdAt", c.CreatedAt.ToString("O")); + cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static void UpdateRow(SqliteConnection conn, Conversation c) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE conversations SET title = @title, updated_at = @updatedAt + WHERE guid = @guid; + """; + cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D")); + cmd.Parameters.AddWithValue("@title", c.Title); + cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static bool Delete(SqliteConnection conn, Guid id) + { + using var tx = conn.BeginTransaction(); + + // Get the row id for cascade delete of messages + var rowId = GetRowId(conn, id); + if (rowId.HasValue) + { + using var delMsgs = conn.CreateCommand(); + delMsgs.CommandText = "DELETE FROM conversation_messages WHERE conversation_id = @id;"; + delMsgs.Parameters.AddWithValue("@id", rowId.Value); + delMsgs.ExecuteNonQuery(); + } + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM conversations WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + var rows = cmd.ExecuteNonQuery(); + + tx.Commit(); + return rows > 0; + } + + private static long? GetRowId(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id FROM conversations WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + var result = cmd.ExecuteScalar(); + return result is long rowId ? rowId : null; + } + + private static Conversation? ReadById(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapConversation(reader) : null; + } + + private static List ReadAll(SqliteConnection conn) + { + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC;"; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapConversation(reader)); + + return results; + } + + private static void InsertMessage(SqliteConnection conn, ConversationMessage m) + { + var convRowId = GetRowId(conn, m.ConversationId) + ?? throw new InvalidOperationException($"Conversation {m.ConversationId} not found"); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO conversation_messages (guid, conversation_id, role, text, created_at) + VALUES (@guid, @conversationId, @role, @text, @createdAt); + """; + cmd.Parameters.AddWithValue("@guid", m.Id.ToString("D")); + cmd.Parameters.AddWithValue("@conversationId", convRowId); + cmd.Parameters.AddWithValue("@role", m.Role); + cmd.Parameters.AddWithValue("@text", m.Text); + cmd.Parameters.AddWithValue("@createdAt", m.CreatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static List ReadMessages(SqliteConnection conn, Guid conversationId) + { + var rowId = GetRowId(conn, conversationId); + if (!rowId.HasValue) + return []; + + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT guid, role, text, created_at + FROM conversation_messages + WHERE conversation_id = @conversationId + ORDER BY created_at ASC; + """; + cmd.Parameters.AddWithValue("@conversationId", rowId.Value); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapMessage(reader, conversationId)); + + return results; + } + + private static Conversation MapConversation(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(0)); + var title = reader.GetString(1); + var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2)); + var updatedAt = reader.IsDBNull(3) ? createdAt : DateTimeOffset.Parse(reader.GetString(3)); + return new Conversation(guid, title, createdAt, updatedAt); + } + + private static ConversationMessage MapMessage(SqliteDataReader reader, Guid conversationId) + { + var guid = Guid.Parse(reader.GetString(0)); + var role = reader.GetString(1); + var text = reader.IsDBNull(2) ? "" : reader.GetString(2); + var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3)); + return new ConversationMessage(guid, conversationId, role, text, createdAt); + } +} diff --git a/Journal.Core/Repositories/SqliteEntryFileRepository.cs b/Journal.Core/Repositories/SqliteEntryFileRepository.cs new file mode 100644 index 0000000..25067d0 --- /dev/null +++ b/Journal.Core/Repositories/SqliteEntryFileRepository.cs @@ -0,0 +1,139 @@ +using Journal.Core.Services.Database; +using Journal.Core.Services.Entries; + +namespace Journal.Core.Repositories; + +public sealed class SqliteEntryFileRepository(IDatabaseSessionService session) : IEntryFileRepository +{ + private const string EntryPrefix = "db://entry/"; + private const string TemplatePrefix = "db://template/"; + private readonly IDatabaseSessionService _session = session; + + public IReadOnlyList ListMarkdownFiles() + { + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT file_name + FROM entry_documents + ORDER BY file_name; + """; + + var paths = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + if (reader.IsDBNull(0)) + continue; + + var fileName = reader.GetString(0); + paths.Add(ToCanonicalPath(fileName)); + } + + return paths; + } + + public string ReadFile(string filePath) + { + var fileName = ResolveFileName(filePath); + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT content + FROM entry_documents + WHERE file_name = @fileName; + """; + cmd.Parameters.AddWithValue("@fileName", fileName); + var result = cmd.ExecuteScalar(); + if (result is null || result is DBNull) + throw new FileNotFoundException($"Entry file not found: {fileName}"); + return Convert.ToString(result) ?? ""; + } + + public void WriteFile(string filePath, string content) + { + var fileName = ResolveFileName(filePath); + var isTemplate = EntryFileNaming.IsTemplateFileName(fileName) ? 1 : 0; + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO entry_documents (guid, file_name, content, is_template, updated_at) + VALUES (@guid, @fileName, @content, @isTemplate, @updatedAt) + ON CONFLICT(file_name) DO UPDATE SET + content = excluded.content, + is_template = excluded.is_template, + updated_at = excluded.updated_at; + """; + cmd.Parameters.AddWithValue("@guid", Guid.NewGuid().ToString("D")); + cmd.Parameters.AddWithValue("@fileName", fileName); + cmd.Parameters.AddWithValue("@content", content ?? ""); + cmd.Parameters.AddWithValue("@isTemplate", isTemplate); + cmd.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); + cmd.ExecuteNonQuery(); + } + + public void AppendFile(string filePath, string content) + { + var fileName = ResolveFileName(filePath); + var existing = FileExists(fileName) ? ReadFile(fileName) : ""; + WriteFile(fileName, existing + content); + } + + public bool FileExists(string filePath) + { + var fileName = ResolveFileName(filePath); + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT 1 + FROM entry_documents + WHERE file_name = @fileName + LIMIT 1; + """; + cmd.Parameters.AddWithValue("@fileName", fileName); + return cmd.ExecuteScalar() is not null; + } + + public string GetFullPath(string filePath) + { + var fileName = ResolveFileName(filePath); + return ToCanonicalPath(fileName); + } + + public string GetFileName(string filePath) => ResolveFileName(filePath); + + public string GetFileNameWithoutExtension(string filePath) + => Path.GetFileNameWithoutExtension(ResolveFileName(filePath)); + + public void EnsureDirectory(string path) { } + + public void DeleteFile(string filePath) + { + var fileName = ResolveFileName(filePath); + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM entry_documents WHERE file_name = @fileName;"; + cmd.Parameters.AddWithValue("@fileName", fileName); + cmd.ExecuteNonQuery(); + } + + private static string ResolveFileName(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return ""; + + if (input.StartsWith(EntryPrefix, StringComparison.OrdinalIgnoreCase)) + return Uri.UnescapeDataString(input[EntryPrefix.Length..]); + if (input.StartsWith(TemplatePrefix, StringComparison.OrdinalIgnoreCase)) + return Uri.UnescapeDataString(input[TemplatePrefix.Length..]); + + var fileName = Path.GetFileName(input); + return fileName ?? input.Trim(); + } + + private static string ToCanonicalPath(string fileName) + { + var prefix = EntryFileNaming.IsTemplateFileName(fileName) ? TemplatePrefix : EntryPrefix; + return prefix + Uri.EscapeDataString(fileName); + } +} diff --git a/Journal.Core/Repositories/SqliteFragmentRepository.cs b/Journal.Core/Repositories/SqliteFragmentRepository.cs new file mode 100644 index 0000000..c58d402 --- /dev/null +++ b/Journal.Core/Repositories/SqliteFragmentRepository.cs @@ -0,0 +1,306 @@ +using Journal.Core.Models; +using Journal.Core.Services.Database; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Repositories; + +public sealed class SqliteFragmentRepository(IDatabaseSessionService session) : IFragmentRepository +{ + private readonly IDatabaseSessionService _session = session; + + public List GetAll() + { + var conn = _session.GetConnection(); + return ReadAllFragments(conn); + } + + public Fragment? GetById(Guid id) + { + var conn = _session.GetConnection(); + return ReadFragment(conn, id); + } + + public void Add(Fragment fragment) + { + ArgumentNullException.ThrowIfNull(fragment); + Normalize(fragment); + var conn = _session.GetConnection(); + InsertFragment(conn, fragment); + } + + public bool Remove(Guid id) + { + var conn = _session.GetConnection(); + return DeleteFragment(conn, id); + } + + public bool Update( + Guid id, + string? type = null, + string? description = null, + IEnumerable? tags = null, + DateTimeOffset? time = null) + { + var conn = _session.GetConnection(); + var existing = ReadFragment(conn, id); + if (existing is null) + return false; + + if (type != null) + { + if (string.IsNullOrWhiteSpace(type)) + throw new ArgumentException("Type cannot be empty", nameof(type)); + existing.Type = type.Trim(); + } + + if (description != null) + { + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description cannot be empty", nameof(description)); + existing.Description = description.Trim(); + } + + if (tags != null) + { + existing.Tags = [.. + tags.Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim())]; + } + + if (time.HasValue) + existing.Time = time.Value; + + UpdateFragmentRow(conn, existing); + return true; + } + + public List GetByTag(string tag) + { + var q = tag?.Trim(); + if (string.IsNullOrWhiteSpace(q)) + return []; + + var conn = _session.GetConnection(); + var all = ReadAllFragments(conn); + return [.. all.Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)))]; + } + + public List GetByType(string type) + { + var q = type?.Trim(); + if (string.IsNullOrWhiteSpace(q)) + return []; + + var conn = _session.GetConnection(); + var all = ReadAllFragments(conn); + return [.. all.Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase))]; + } + + public List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + { + var conn = _session.GetConnection(); + IEnumerable results = ReadAllFragments(conn); + + var qType = type?.Trim(); + var qTag = tag?.Trim(); + + if (!string.IsNullOrWhiteSpace(qType)) + results = results.Where(f => f.Type.Contains(qType, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(qTag)) + results = results.Where(f => f.Tags.Any(t => t.Contains(qTag, StringComparison.OrdinalIgnoreCase))); + if (timeAfter.HasValue) + results = results.Where(f => f.Time > timeAfter.Value); + + return [.. results]; + } + + // ── Private helpers ────────────────────────────────────────────── + + private static void InsertFragment(SqliteConnection conn, Fragment f) + { + using var tx = conn.BeginTransaction(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO fragments (guid, entry_id, type, description, time) + VALUES (@guid, NULL, @type, @description, @time); + """; + cmd.Parameters.AddWithValue("@guid", f.Id.ToString("D")); + cmd.Parameters.AddWithValue("@type", f.Type); + cmd.Parameters.AddWithValue("@description", f.Description); + cmd.Parameters.AddWithValue("@time", f.Time.ToString("O")); + cmd.ExecuteNonQuery(); + + var fragmentRowId = GetFragmentRowId(conn, f.Id); + if (fragmentRowId.HasValue) + InsertTags(conn, fragmentRowId.Value, f.Tags); + + tx.Commit(); + } + + private static void UpdateFragmentRow(SqliteConnection conn, Fragment f) + { + using var tx = conn.BeginTransaction(); + + using var upd = conn.CreateCommand(); + upd.CommandText = """ + UPDATE fragments SET type = @type, description = @description, time = @time + WHERE guid = @guid AND entry_id IS NULL; + """; + upd.Parameters.AddWithValue("@guid", f.Id.ToString("D")); + upd.Parameters.AddWithValue("@type", f.Type); + upd.Parameters.AddWithValue("@description", f.Description); + upd.Parameters.AddWithValue("@time", f.Time.ToString("O")); + upd.ExecuteNonQuery(); + + var fragmentRowId = GetFragmentRowId(conn, f.Id); + if (fragmentRowId.HasValue) + { + using var del = conn.CreateCommand(); + del.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;"; + del.Parameters.AddWithValue("@id", fragmentRowId.Value); + del.ExecuteNonQuery(); + + InsertTags(conn, fragmentRowId.Value, f.Tags); + } + + tx.Commit(); + } + + private static bool DeleteFragment(SqliteConnection conn, Guid id) + { + using var tx = conn.BeginTransaction(); + + var fragmentRowId = GetFragmentRowId(conn, id); + if (fragmentRowId.HasValue) + { + using var delTags = conn.CreateCommand(); + delTags.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;"; + delTags.Parameters.AddWithValue("@id", fragmentRowId.Value); + delTags.ExecuteNonQuery(); + } + + using var delFrag = conn.CreateCommand(); + delFrag.CommandText = "DELETE FROM fragments WHERE guid = @guid AND entry_id IS NULL;"; + delFrag.Parameters.AddWithValue("@guid", id.ToString("D")); + var rows = delFrag.ExecuteNonQuery(); + + tx.Commit(); + return rows > 0; + } + + private static Fragment? ReadFragment(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, guid, type, description, time + FROM fragments WHERE guid = @guid AND entry_id IS NULL; + """; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + return null; + + var fragment = MapRow(reader); + fragment.Tags = ReadTags(conn, reader.GetInt64(0)); + return fragment; + } + + private static List ReadAllFragments(SqliteConnection conn) + { + var fragments = new List(); + var rowIds = new List(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, guid, type, description, time + FROM fragments WHERE guid IS NOT NULL AND entry_id IS NULL + ORDER BY time; + """; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + fragments.Add(MapRow(reader)); + rowIds.Add(reader.GetInt64(0)); + } + + for (var i = 0; i < fragments.Count; i++) + fragments[i].Tags = ReadTags(conn, rowIds[i]); + + return fragments; + } + + private static List ReadTags(SqliteConnection conn, long fragmentRowId) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT t.name FROM tags t + INNER JOIN fragment_tags ft ON ft.tag_id = t.id + WHERE ft.fragment_id = @id + ORDER BY t.name; + """; + cmd.Parameters.AddWithValue("@id", fragmentRowId); + + var tags = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + tags.Add(reader.GetString(0)); + + return tags; + } + + private static long? GetFragmentRowId(SqliteConnection conn, Guid guid) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id FROM fragments WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", guid.ToString("D")); + var result = cmd.ExecuteScalar(); + return result is long id ? id : null; + } + + private static void InsertTags(SqliteConnection conn, long fragmentRowId, List tags) + { + if (tags.Count == 0) return; + + foreach (var tag in tags) + { + using var upsert = conn.CreateCommand(); + upsert.CommandText = "INSERT OR IGNORE INTO tags (name) VALUES (@name);"; + upsert.Parameters.AddWithValue("@name", tag); + upsert.ExecuteNonQuery(); + + using var getTagId = conn.CreateCommand(); + getTagId.CommandText = "SELECT id FROM tags WHERE name = @name;"; + getTagId.Parameters.AddWithValue("@name", tag); + var tagId = (long)getTagId.ExecuteScalar()!; + + using var link = conn.CreateCommand(); + link.CommandText = "INSERT OR IGNORE INTO fragment_tags (fragment_id, tag_id) VALUES (@fid, @tid);"; + link.Parameters.AddWithValue("@fid", fragmentRowId); + link.Parameters.AddWithValue("@tid", tagId); + link.ExecuteNonQuery(); + } + } + + private static Fragment MapRow(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(1)); + var type = reader.GetString(2); + var description = reader.IsDBNull(3) ? "" : reader.GetString(3); + var time = reader.IsDBNull(4) + ? DateTimeOffset.MinValue + : DateTimeOffset.Parse(reader.GetString(4)); + return new Fragment(guid, type, description, time); + } + + private static void Normalize(Fragment fragment) + { + fragment.Type = fragment.Type.Trim(); + fragment.Description = fragment.Description.Trim(); + fragment.Tags = [.. + fragment.Tags.Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim())]; + } +} diff --git a/Journal.Core/Repositories/SqliteListRepository.cs b/Journal.Core/Repositories/SqliteListRepository.cs new file mode 100644 index 0000000..4a51225 --- /dev/null +++ b/Journal.Core/Repositories/SqliteListRepository.cs @@ -0,0 +1,129 @@ +using Journal.Core.Models; +using Journal.Core.Services.Database; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Repositories; + +public sealed class SqliteListRepository(IDatabaseSessionService session) : IListRepository +{ + private readonly IDatabaseSessionService _session = session; + + public List GetAll() + { + var conn = _session.GetConnection(); + return ReadAll(conn); + } + + public ListDocument? GetById(Guid id) + { + var conn = _session.GetConnection(); + return ReadById(conn, id); + } + + public void Add(ListDocument list) + { + ArgumentNullException.ThrowIfNull(list); + var conn = _session.GetConnection(); + Insert(conn, list); + } + + public bool Update(Guid id, string? label = null, string? content = null) + { + var conn = _session.GetConnection(); + var existing = ReadById(conn, id); + if (existing is null) + return false; + + if (label is not null) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label cannot be empty", nameof(label)); + existing.Label = label.Trim(); + } + + if (content is not null) + existing.Content = content; + + existing.UpdatedAt = DateTimeOffset.Now; + UpdateRow(conn, existing); + return true; + } + + public bool Remove(Guid id) + { + var conn = _session.GetConnection(); + return Delete(conn, id); + } + + // ── Private helpers ────────────────────────────────────────────── + + private static void Insert(SqliteConnection conn, ListDocument list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO lists (guid, label, content, created_at, updated_at) + VALUES (@guid, @label, @content, @createdAt, @updatedAt); + """; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.Parameters.AddWithValue("@content", list.Content); + cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O")); + cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static void UpdateRow(SqliteConnection conn, ListDocument list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE lists SET label = @label, content = @content, updated_at = @updatedAt + WHERE guid = @guid; + """; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.Parameters.AddWithValue("@content", list.Content); + cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static bool Delete(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + return cmd.ExecuteNonQuery() > 0; + } + + private static ListDocument? ReadById(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapRow(reader) : null; + } + + private static List ReadAll(SqliteConnection conn) + { + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists ORDER BY created_at;"; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapRow(reader)); + + return results; + } + + private static ListDocument MapRow(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(0)); + var label = reader.GetString(1); + var content = reader.IsDBNull(2) ? "" : reader.GetString(2); + var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3)); + var updatedAt = reader.IsDBNull(4) ? createdAt : DateTimeOffset.Parse(reader.GetString(4)); + return new ListDocument(guid, label, content, createdAt, updatedAt); + } +} diff --git a/Journal.Core/Repositories/SqliteTodoRepository.cs b/Journal.Core/Repositories/SqliteTodoRepository.cs new file mode 100644 index 0000000..725dfe8 --- /dev/null +++ b/Journal.Core/Repositories/SqliteTodoRepository.cs @@ -0,0 +1,279 @@ +using Journal.Core.Models; +using Journal.Core.Services.Database; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Repositories; + +public sealed class SqliteTodoRepository(IDatabaseSessionService session) : ITodoRepository +{ + private readonly IDatabaseSessionService _session = session; + + // ── Lists ──────────────────────────────────────────────────────── + + public List GetAllLists() + { + var conn = _session.GetConnection(); + return ReadAllLists(conn); + } + + public TodoList? GetListById(Guid id) + { + var conn = _session.GetConnection(); + return ReadListById(conn, id); + } + + public void AddList(TodoList list) + { + ArgumentNullException.ThrowIfNull(list); + var conn = _session.GetConnection(); + InsertList(conn, list); + } + + public bool UpdateList(Guid id, string? label = null) + { + var conn = _session.GetConnection(); + var existing = ReadListById(conn, id); + if (existing is null) + return false; + + if (label is not null) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label cannot be empty", nameof(label)); + existing.Label = label.Trim(); + } + + UpdateListRow(conn, existing); + return true; + } + + public bool RemoveList(Guid id) + { + var conn = _session.GetConnection(); + return DeleteList(conn, id); + } + + // ── Items ──────────────────────────────────────────────────────── + + public List GetItemsByListId(Guid listId) + { + var conn = _session.GetConnection(); + return ReadItemsByListId(conn, listId); + } + + public TodoItem? GetItemById(Guid id) + { + var conn = _session.GetConnection(); + return ReadItemById(conn, id); + } + + public void AddItem(TodoItem item) + { + ArgumentNullException.ThrowIfNull(item); + var conn = _session.GetConnection(); + InsertItem(conn, item); + } + + public bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null) + { + var conn = _session.GetConnection(); + var existing = ReadItemById(conn, id); + if (existing is null) + return false; + + if (text is not null) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text cannot be empty", nameof(text)); + existing.Text = text.Trim(); + } + + if (done.HasValue) + existing.Done = done.Value; + + if (sortOrder.HasValue) + existing.SortOrder = sortOrder.Value; + + UpdateItemRow(conn, existing); + return true; + } + + public bool RemoveItem(Guid id) + { + var conn = _session.GetConnection(); + return DeleteItem(conn, id); + } + + // ── Private list helpers ───────────────────────────────────────── + + private static void InsertList(SqliteConnection conn, TodoList list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO todo_lists (guid, label, created_at) + VALUES (@guid, @label, @createdAt); + """; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static void UpdateListRow(SqliteConnection conn, TodoList list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE todo_lists SET label = @label WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.ExecuteNonQuery(); + } + + private static bool DeleteList(SqliteConnection conn, Guid id) + { + using var tx = conn.BeginTransaction(); + + var rowId = GetListRowId(conn, id); + if (rowId.HasValue) + { + using var delItems = conn.CreateCommand(); + delItems.CommandText = "DELETE FROM todo_items WHERE list_id = @listId;"; + delItems.Parameters.AddWithValue("@listId", rowId.Value); + delItems.ExecuteNonQuery(); + } + + using var delList = conn.CreateCommand(); + delList.CommandText = "DELETE FROM todo_lists WHERE guid = @guid;"; + delList.Parameters.AddWithValue("@guid", id.ToString("D")); + var rows = delList.ExecuteNonQuery(); + + tx.Commit(); + return rows > 0; + } + + private static TodoList? ReadListById(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapListRow(reader) : null; + } + + private static List ReadAllLists(SqliteConnection conn) + { + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists ORDER BY created_at;"; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapListRow(reader)); + + return results; + } + + private static long? GetListRowId(SqliteConnection conn, Guid guid) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id FROM todo_lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", guid.ToString("D")); + var result = cmd.ExecuteScalar(); + return result is long id ? id : null; + } + + private static TodoList MapListRow(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(0)); + var label = reader.GetString(1); + var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2)); + return new TodoList(guid, label, createdAt); + } + + // ── Private item helpers ───────────────────────────────────────── + + private static void InsertItem(SqliteConnection conn, TodoItem item) + { + var listRowId = GetListRowId(conn, item.ListId) + ?? throw new InvalidOperationException($"Todo list {item.ListId} not found"); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO todo_items (guid, list_id, text, done, sort_order) + VALUES (@guid, @listId, @text, @done, @sortOrder); + """; + cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D")); + cmd.Parameters.AddWithValue("@listId", listRowId); + cmd.Parameters.AddWithValue("@text", item.Text); + cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0); + cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder); + cmd.ExecuteNonQuery(); + } + + private static void UpdateItemRow(SqliteConnection conn, TodoItem item) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE todo_items SET text = @text, done = @done, sort_order = @sortOrder + WHERE guid = @guid; + """; + cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D")); + cmd.Parameters.AddWithValue("@text", item.Text); + cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0); + cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder); + cmd.ExecuteNonQuery(); + } + + private static bool DeleteItem(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM todo_items WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + return cmd.ExecuteNonQuery() > 0; + } + + private static TodoItem? ReadItemById(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order + FROM todo_items ti + INNER JOIN todo_lists tl ON tl.id = ti.list_id + WHERE ti.guid = @guid; + """; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapItemRow(reader) : null; + } + + private static List ReadItemsByListId(SqliteConnection conn, Guid listId) + { + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order + FROM todo_items ti + INNER JOIN todo_lists tl ON tl.id = ti.list_id + WHERE tl.guid = @listGuid + ORDER BY ti.sort_order, ti.guid; + """; + cmd.Parameters.AddWithValue("@listGuid", listId.ToString("D")); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapItemRow(reader)); + + return results; + } + + private static TodoItem MapItemRow(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(0)); + var listGuid = Guid.Parse(reader.GetString(1)); + var text = reader.GetString(2); + var done = !reader.IsDBNull(3) && reader.GetInt64(3) != 0; + var sortOrder = reader.IsDBNull(4) ? 0 : (int)reader.GetInt64(4); + return new TodoItem(guid, listGuid, text, done, sortOrder); + } +} diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b157dc0 --- /dev/null +++ b/Journal.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; +using Journal.Core.Repositories; +using Journal.Core.Services.Ai; +using Journal.Core.Services.Config; +using Journal.Core.Services.Database; +using Journal.Core.Services.Entries; +using Journal.Core.Services.Fragments; +using Journal.Core.Services.Lists; +using Journal.Core.Services.Logging; +using Journal.Core.Services.Sidecar; +using Journal.Core.Services.Speech; +using Journal.Core.Services.Conversations; +using Journal.Core.Services.Todos; +using Journal.Core.Services.Vault; + +namespace Journal.Core; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFragmentServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + { + var config = provider.GetRequiredService().Current; + return new DisabledAiService(config.AiProvider); + }); + services.AddSingleton( + new DisabledSpeechBridgeService("none")); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new DisabledCoachService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/Journal.Core/Services/Ai/DisabledAiService.cs b/Journal.Core/Services/Ai/DisabledAiService.cs new file mode 100644 index 0000000..c44443e --- /dev/null +++ b/Journal.Core/Services/Ai/DisabledAiService.cs @@ -0,0 +1,28 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Ai; + +public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService +{ + private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); + private readonly string _message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim(); + private readonly bool _healthy = healthy; + + public Task HealthAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message)); + + public Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default) => + Task.FromResult(_message); + + public Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default) => + Task.FromResult(_message); + + public Task ChatAsync(string prompt, CancellationToken cancellationToken = default) => + Task.FromResult(_message); + + public Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default) => + Task.FromResult(_message); + + public Task> EmbedAsync(string content, CancellationToken cancellationToken = default) => + Task.FromResult>([]); +} diff --git a/Journal.Core/Services/Ai/DisabledCoachService.cs b/Journal.Core/Services/Ai/DisabledCoachService.cs new file mode 100644 index 0000000..6b90fb9 --- /dev/null +++ b/Journal.Core/Services/Ai/DisabledCoachService.cs @@ -0,0 +1,26 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Ai; + +public sealed class DisabledCoachService(string message = "Coach is not available. Set JOURNAL_AI_PROVIDER to enable.") : ICoachService +{ + private readonly string _message = message; + + public Task DailyCheckInAsync(CoachContextDto context, CancellationToken cancellationToken = default) + => Task.FromResult(Disabled("daily_checkin")); + + public Task EveningReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default) + => Task.FromResult(Disabled("evening_review")); + + public Task WeeklyReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default) + => Task.FromResult(Disabled("weekly_review")); + + private CoachPlanDto Disabled(string kind) => new( + Kind: kind, + Title: "Coach Disabled", + Summary: _message, + Questions: [], + SuggestedNextActions: [], + SuggestedTags: [], + Evidence: []); +} diff --git a/Journal.Core/Services/Ai/IAiService.cs b/Journal.Core/Services/Ai/IAiService.cs new file mode 100644 index 0000000..0621e98 --- /dev/null +++ b/Journal.Core/Services/Ai/IAiService.cs @@ -0,0 +1,13 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Ai; + +public interface IAiService +{ + Task HealthAsync(CancellationToken cancellationToken = default); + Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default); + Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default); + Task ChatAsync(string prompt, CancellationToken cancellationToken = default); + Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default); + Task> EmbedAsync(string content, CancellationToken cancellationToken = default); +} diff --git a/Journal.Core/Services/Ai/ICoachService.cs b/Journal.Core/Services/Ai/ICoachService.cs new file mode 100644 index 0000000..e2a63d4 --- /dev/null +++ b/Journal.Core/Services/Ai/ICoachService.cs @@ -0,0 +1,10 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Ai; + +public interface ICoachService +{ + Task DailyCheckInAsync(CoachContextDto context, CancellationToken cancellationToken = default); + Task EveningReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default); + Task WeeklyReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default); +} diff --git a/Journal.Core/Services/Config/IJournalConfigService.cs b/Journal.Core/Services/Config/IJournalConfigService.cs new file mode 100644 index 0000000..5da1f33 --- /dev/null +++ b/Journal.Core/Services/Config/IJournalConfigService.cs @@ -0,0 +1,8 @@ +using Journal.Core.Models; + +namespace Journal.Core.Services.Config; + +public interface IJournalConfigService +{ + JournalConfig Current { get; } +} diff --git a/Journal.Core/Services/Config/JournalConfigService.cs b/Journal.Core/Services/Config/JournalConfigService.cs new file mode 100644 index 0000000..c73b3b6 --- /dev/null +++ b/Journal.Core/Services/Config/JournalConfigService.cs @@ -0,0 +1,112 @@ +using Journal.Core.Models; + +namespace Journal.Core.Services.Config; + +public sealed class JournalConfigService : IJournalConfigService +{ + public JournalConfig Current => BuildConfig(); + + private static JournalConfig BuildConfig() + { + var projectRoot = ResolveProjectRoot(); + var appDirectory = ResolvePath("JOURNAL_APP_DIR", Path.Combine(projectRoot, "journal")); + + var vaultDirectory = ResolvePath("JOURNAL_VAULT_DIR", Path.Combine(appDirectory, "vault")); + var logDirectory = ResolvePath("JOURNAL_LOG_DIR", Path.Combine(projectRoot, "logs")); + + var pidFile = ResolvePath("JOURNAL_PID_FILE", Path.Combine(logDirectory, "nicegui_server.pid")); + var serverControlFile = ResolvePath("JOURNAL_SERVER_CONTROL_FILE", Path.Combine(logDirectory, "server_control.action")); + + var nlpBackend = (Environment.GetEnvironmentVariable("JOURNAL_NLP_BACKEND") ?? "auto").Trim().ToLowerInvariant(); + if (nlpBackend is not ("auto" or "spacy" or "fallback")) + nlpBackend = "auto"; + + var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "llamasharp").Trim().ToLowerInvariant(); + if (aiProvider is not ("none" or "llamasharp")) + aiProvider = "none"; + + return new JournalConfig( + ProjectRoot: projectRoot, + AppDirectory: appDirectory, + VaultDirectory: vaultDirectory, + LogDirectory: logDirectory, + PidFile: pidFile, + ServerControlFile: serverControlFile, + DatabaseFilename: Environment.GetEnvironmentVariable("JOURNAL_DATABASE_FILENAME") ?? "journal_cache.db", + CloudAiApiKey: Environment.GetEnvironmentVariable("CLOUDAI_API_KEY") ?? "", + CloudAiApiUrl: Environment.GetEnvironmentVariable("CLOUDAI_API_URL") ?? "", + LlamaCppUrl: Environment.GetEnvironmentVariable("LLAMA_CPP_URL") ?? "http://127.0.0.1:8085/v1/completions", + LlamaCppModel: Environment.GetEnvironmentVariable("LLAMA_CPP_MODEL") ?? "qwen/qwen3-4b", + LlamaCppTimeout: ParseInt("LLAMA_CPP_TIMEOUT", 6000), + EmbeddingApiUrl: Environment.GetEnvironmentVariable("EMBEDDING_API_URL") ?? "http://127.0.0.1:8086/v1/embeddings", + EmbeddingModelName: Environment.GetEnvironmentVariable("EMBEDDING_MODEL_NAME") ?? "text-embedding-nomic-embed-text-v2-moe", + ModelContextTokens: ParseInt("MODEL_CONTEXT_TOKENS", 131072), + ChunkTokenBudget: ParseInt("CHUNK_TOKEN_BUDGET", 120000), + MicrophoneDeviceIndex: ParseNullableInt("MICROPHONE_DEVICE_INDEX"), + SpeechRecognitionEngine: Environment.GetEnvironmentVariable("SPEECH_RECOGNITION_ENGINE") ?? "whisper", + WhisperModelSize: Environment.GetEnvironmentVariable("WHISPER_MODEL_SIZE") ?? "base", + NlpBackend: nlpBackend, + AiProvider: aiProvider, + GgufModelPath: ResolveGgufModelPath(projectRoot)); + } + + private static string ResolveProjectRoot() + { + var envRoot = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); + if (!string.IsNullOrWhiteSpace(envRoot)) + return Path.GetFullPath(envRoot); + + var cwd = Directory.GetCurrentDirectory(); + if (Directory.Exists(Path.Combine(cwd, "journal"))) + return Path.GetFullPath(cwd); + + var upOne = Path.GetFullPath(Path.Combine(cwd, "..")); + if (Directory.Exists(Path.Combine(upOne, "journal"))) + return upOne; + + var upTwo = Path.GetFullPath(Path.Combine(cwd, "..", "..")); + if (Directory.Exists(Path.Combine(upTwo, "journal"))) + return upTwo; + + return Path.GetFullPath(cwd); + } + + private static string ResolvePath(string envVar, string defaultPath) + { + var value = Environment.GetEnvironmentVariable(envVar); + var raw = string.IsNullOrWhiteSpace(value) ? defaultPath : value; + return Path.GetFullPath(raw); + } + + private static int ParseInt(string envVar, int defaultValue) + { + var value = Environment.GetEnvironmentVariable(envVar); + return int.TryParse(value, out var parsed) ? parsed : defaultValue; + } + + private static int? ParseNullableInt(string envVar) + { + var value = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrWhiteSpace(value)) + return null; + return int.TryParse(value, out var parsed) ? parsed : null; + } + + private static string ResolveGgufModelPath(string projectRoot) + { + var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_GGUF_MODEL_PATH"); + if (!string.IsNullOrWhiteSpace(fromEnv)) + return Path.GetFullPath(fromEnv); + + var modelsDir = Path.Combine(projectRoot, "models"); + if (Directory.Exists(modelsDir)) + { + var first = Directory.EnumerateFiles(modelsDir, "*.gguf").FirstOrDefault(); + if (first is not null) + return Path.GetFullPath(first); + } + + return Path.Combine(modelsDir, "model.gguf"); + } +} + diff --git a/Journal.Core/Services/Conversations/ConversationService.cs b/Journal.Core/Services/Conversations/ConversationService.cs new file mode 100644 index 0000000..c1949f1 --- /dev/null +++ b/Journal.Core/Services/Conversations/ConversationService.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Conversations; + +public class ConversationService(IConversationRepository repo) : IConversationService +{ + private readonly IConversationRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + private static ConversationDto MapSummary(Conversation c) => new(c.Id, c.Title, c.CreatedAt, c.UpdatedAt); + + private static ConversationMessageDto MapMessage(ConversationMessage m) => new(m.Id, m.Role, m.Text, m.CreatedAt); + + public List GetAll() + { + var items = _repo.GetAll(); + return [.. items.Select(MapSummary)]; + } + + public ConversationDetailDto? GetById(Guid id) + { + var c = _repo.GetById(id); + if (c is null) return null; + + var messages = _repo.GetMessages(id); + return new ConversationDetailDto( + c.Id, c.Title, + [.. messages.Select(MapMessage)], + c.CreatedAt, c.UpdatedAt); + } + + public ConversationDto Create(CreateConversationDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + var conversation = new Conversation(dto.Title); + _repo.Add(conversation); + return MapSummary(conversation); + } + + public bool Update(Guid id, UpdateConversationDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Title is not null && string.IsNullOrWhiteSpace(dto.Title)) + throw new ValidationException("Title cannot be empty"); + + return _repo.Update(id, dto.Title?.Trim()); + } + + public bool Remove(Guid id) => _repo.Remove(id); + + public ConversationMessageDto AddMessage(Guid conversationId, string role, string text) + { + var message = new ConversationMessage(conversationId, role, text); + _repo.AddMessage(message); + return MapMessage(message); + } + + public List GetMessages(Guid conversationId) + { + var messages = _repo.GetMessages(conversationId); + return [.. messages.Select(MapMessage)]; + } +} diff --git a/Journal.Core/Services/Conversations/IConversationService.cs b/Journal.Core/Services/Conversations/IConversationService.cs new file mode 100644 index 0000000..92c79b4 --- /dev/null +++ b/Journal.Core/Services/Conversations/IConversationService.cs @@ -0,0 +1,14 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Conversations; + +public interface IConversationService +{ + List GetAll(); + ConversationDetailDto? GetById(Guid id); + ConversationDto Create(CreateConversationDto dto); + bool Update(Guid id, UpdateConversationDto dto); + bool Remove(Guid id); + ConversationMessageDto AddMessage(Guid conversationId, string role, string text); + List GetMessages(Guid conversationId); +} diff --git a/Journal.Core/Services/Database/DatabaseSessionService.cs b/Journal.Core/Services/Database/DatabaseSessionService.cs new file mode 100644 index 0000000..80ddee6 --- /dev/null +++ b/Journal.Core/Services/Database/DatabaseSessionService.cs @@ -0,0 +1,79 @@ +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services.Database; + +public sealed class DatabaseSessionService(IJournalDatabaseService database) : IDatabaseSessionService, IDisposable +{ + private readonly IJournalDatabaseService _database = database; + private readonly Lock _lock = new(); + private string? _password; + private SqliteConnection? _connection; + + public bool IsUnlocked + { + get + { + lock (_lock) { return _password is not null; } + } + } + + public void SetPassword(string password) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + + lock (_lock) + { + if (_connection is not null && _password != password) + { + _connection.Dispose(); + _connection = null; + } + + _password = password; + } + } + + public bool TryGetSession(out string password) + { + lock (_lock) + { + if (string.IsNullOrWhiteSpace(_password)) + { + password = ""; + return false; + } + + password = _password; + return true; + } + } + + public SqliteConnection GetConnection() + { + lock (_lock) + { + if (_password is null) + throw new InvalidOperationException( + "Database is locked. Authenticate first (e.g. vault.load_all or db.hydrate_workspace)."); + + if (_connection is not null) + return _connection; + + _connection = _database.OpenEncryptedConnection(_password); + _database.EnsureSchema(_connection); + return _connection; + } + } + + public void CloseConnection() + { + lock (_lock) + { + _connection?.Dispose(); + _connection = null; + } + } + + public void Dispose() => CloseConnection(); +} diff --git a/Journal.Core/Services/Database/IDatabaseSessionService.cs b/Journal.Core/Services/Database/IDatabaseSessionService.cs new file mode 100644 index 0000000..379d0b6 --- /dev/null +++ b/Journal.Core/Services/Database/IDatabaseSessionService.cs @@ -0,0 +1,12 @@ +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services.Database; + +public interface IDatabaseSessionService +{ + bool IsUnlocked { get; } + void SetPassword(string password); + bool TryGetSession(out string password); + SqliteConnection GetConnection(); + void CloseConnection(); +} diff --git a/Journal.Core/Services/Database/IJournalDatabaseService.cs b/Journal.Core/Services/Database/IJournalDatabaseService.cs new file mode 100644 index 0000000..77ef2e9 --- /dev/null +++ b/Journal.Core/Services/Database/IJournalDatabaseService.cs @@ -0,0 +1,16 @@ +using Journal.Core.Dtos; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services.Database; + +public interface IJournalDatabaseService +{ + string GetDatabasePath(); + byte[] DeriveDatabaseKey(string password); + string BuildPragmaKeyStatement(string password); + IReadOnlyDictionary GetSchemaStatements(); + SqliteConnection OpenEncryptedConnection(string password); + void EnsureSchema(SqliteConnection connection); + JournalDatabaseStatus GetStatus(string password); + JournalDatabaseHydrationResult HydrateWorkspace(string password); +} diff --git a/Journal.Core/Services/Database/JournalDatabaseService.cs b/Journal.Core/Services/Database/JournalDatabaseService.cs new file mode 100644 index 0000000..b5eb46c --- /dev/null +++ b/Journal.Core/Services/Database/JournalDatabaseService.cs @@ -0,0 +1,285 @@ +using System.Security.Cryptography; +using System.Text; +using System.Globalization; +using Journal.Core.Dtos; +using Journal.Core.Services.Config; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Services.Database; + +public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService +{ + public const int KeySize = 32; + public const int Iterations = 600_000; + private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv"); + private static readonly Lock SqliteInitLock = new(); + private static bool _sqliteInitialized; + private static readonly IReadOnlyList RequiredSchemaTables = + ["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents", "conversations", "conversation_messages"]; + + private readonly IJournalConfigService _config = config; + + public string GetDatabasePath() + { + var directory = ResolveDatabaseDirectory(); + + Directory.CreateDirectory(directory); + return Path.GetFullPath(Path.Combine(directory, _config.Current.DatabaseFilename)); + } + + public byte[] DeriveDatabaseKey(string password) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + + return Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + DatabaseKeySalt, + Iterations, + HashAlgorithmName.SHA256, + KeySize); + } + + public string BuildPragmaKeyStatement(string password) + { + var dbKeyHex = Convert.ToHexString(DeriveDatabaseKey(password)).ToLowerInvariant(); + return $"PRAGMA key = \"x'{dbKeyHex}'\""; + } + + public IReadOnlyDictionary GetSchemaStatements() + { + return new Dictionary(StringComparer.Ordinal) + { + ["entries"] = """ + CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE + ); + """, + ["sections"] = """ + CREATE TABLE IF NOT EXISTS sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + FOREIGN KEY (entry_id) REFERENCES entries (id) + ); + """, + ["fragments"] = """ + CREATE TABLE IF NOT EXISTS fragments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + entry_id INTEGER, + type TEXT NOT NULL, + description TEXT, + time TEXT, + FOREIGN KEY (entry_id) REFERENCES entries (id) + ); + """, + ["tags"] = """ + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + ); + """, + ["fragment_tags"] = """ + CREATE TABLE IF NOT EXISTS fragment_tags ( + fragment_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (fragment_id, tag_id), + FOREIGN KEY (fragment_id) REFERENCES fragments (id), + FOREIGN KEY (tag_id) REFERENCES tags (id) + ); + """, + ["lists"] = """ + CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + label TEXT NOT NULL, + content TEXT, + created_at TEXT, + updated_at TEXT + ); + """, + ["todo_lists"] = """ + CREATE TABLE IF NOT EXISTS todo_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + label TEXT NOT NULL, + created_at TEXT + ); + """, + ["todo_items"] = """ + CREATE TABLE IF NOT EXISTS todo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + list_id INTEGER NOT NULL, + text TEXT NOT NULL, + done INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (list_id) REFERENCES todo_lists (id) + ); + """, + ["entry_documents"] = """ + CREATE TABLE IF NOT EXISTS entry_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + file_name TEXT NOT NULL UNIQUE, + content TEXT NOT NULL, + is_template INTEGER NOT NULL DEFAULT 0, + updated_at TEXT + ); + """, + ["conversations"] = """ + CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + title TEXT NOT NULL, + created_at TEXT, + updated_at TEXT + ); + """, + ["conversation_messages"] = """ + CREATE TABLE IF NOT EXISTS conversation_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + conversation_id INTEGER NOT NULL, + role TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT, + FOREIGN KEY (conversation_id) REFERENCES conversations (id) + ); + """ + }; + } + + public JournalDatabaseStatus GetStatus(string password) + { + var tables = GetSchemaStatements().Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray(); + var (Ready, Message) = ProbeRuntime(password); + return new JournalDatabaseStatus( + DatabasePath: GetDatabasePath(), + KeyLengthBytes: DeriveDatabaseKey(password).Length, + Iterations: Iterations, + KeyDerivation: "PBKDF2-HMAC-SHA256", + SchemaTables: tables, + RuntimeReady: Ready, + RuntimeMessage: Message); + } + + public JournalDatabaseHydrationResult HydrateWorkspace(string password) + { + using var connection = OpenEncryptedConnection(password); + EnsureSchema(connection); + var runtimeReady = HasRequiredTables(connection); + + var entryDocumentsProcessed = CountEntryDocuments(connection); + + return new JournalDatabaseHydrationResult( + DatabasePath: GetDatabasePath(), + EntryFilesProcessed: entryDocumentsProcessed, + RuntimeReady: runtimeReady, + Message: runtimeReady + ? "Workspace hydration completed with SQLCipher runtime schema validation and document store readiness." + : "Workspace hydration completed, but required SQLCipher schema tables were not found."); + } + + private static void EnsureSqliteInitialized() + { + if (_sqliteInitialized) + return; + + lock (SqliteInitLock) + { + if (_sqliteInitialized) + return; + + SQLitePCL.Batteries_V2.Init(); + _sqliteInitialized = true; + } + } + + public SqliteConnection OpenEncryptedConnection(string password) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + + EnsureSqliteInitialized(); + + var connection = new SqliteConnection($"Data Source={GetDatabasePath()};Mode=ReadWriteCreate;Pooling=False"); + connection.Open(); + + using var keyCmd = connection.CreateCommand(); + keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";"; + keyCmd.ExecuteNonQuery(); + + using var verifyCmd = connection.CreateCommand(); + verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; + _ = verifyCmd.ExecuteScalar(); + + return connection; + } + + public void EnsureSchema(SqliteConnection connection) + { + foreach (var statement in GetSchemaStatements().Values) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = statement; + cmd.ExecuteNonQuery(); + } + } + + private static bool HasRequiredTables(SqliteConnection connection) + { + var existing = new HashSet(StringComparer.OrdinalIgnoreCase); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table'"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + if (!reader.IsDBNull(0)) + existing.Add(reader.GetString(0)); + } + + return RequiredSchemaTables.All(existing.Contains); + } + + private (bool Ready, string Message) ProbeRuntime(string password) + { + try + { + using var connection = OpenEncryptedConnection(password); + EnsureSchema(connection); + var ready = HasRequiredTables(connection); + return ready + ? (true, "SQLCipher runtime is available and schema tables are present.") + : (false, "SQLCipher runtime opened, but required schema tables are missing."); + } + catch (Exception ex) + { + return (false, $"SQLCipher runtime check failed: {ex.Message}"); + } + } + + private static int CountEntryDocuments(SqliteConnection connection) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM entry_documents;"; + var scalar = cmd.ExecuteScalar(); + if (scalar is null || scalar is DBNull) + return 0; + + return Convert.ToInt32(scalar, CultureInfo.InvariantCulture); + } + + private string ResolveDatabaseDirectory() + { + var overrideDir = Environment.GetEnvironmentVariable("JOURNAL_DATABASE_DIR"); + if (!string.IsNullOrWhiteSpace(overrideDir)) + return Path.GetFullPath(overrideDir); + + return Path.GetFullPath(Path.Combine(_config.Current.VaultDirectory, "db")); + } + +} diff --git a/Journal.Core/Services/Entries/EntryFileNaming.cs b/Journal.Core/Services/Entries/EntryFileNaming.cs new file mode 100644 index 0000000..7b800dd --- /dev/null +++ b/Journal.Core/Services/Entries/EntryFileNaming.cs @@ -0,0 +1,9 @@ +namespace Journal.Core.Services.Entries; + +internal static class EntryFileNaming +{ + internal const string TemplateSuffix = ".template.md"; + + internal static bool IsTemplateFileName(string fileName) + => fileName.EndsWith(TemplateSuffix, StringComparison.OrdinalIgnoreCase); +} diff --git a/Journal.Core/Services/Entries/EntryFileService.cs b/Journal.Core/Services/Entries/EntryFileService.cs new file mode 100644 index 0000000..6ac0b37 --- /dev/null +++ b/Journal.Core/Services/Entries/EntryFileService.cs @@ -0,0 +1,167 @@ +using Journal.Core.Dtos; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Entries; + +public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService +{ + private readonly IEntryFileRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + public IReadOnlyList ListEntries() + { + return [.. _repo.ListMarkdownFiles() + .Where(path => !EntryFileNaming.IsTemplateFileName(_repo.GetFileName(path))) + .Select(path => new EntryListItem( + FileName: _repo.GetFileName(path), + FilePath: _repo.GetFullPath(path)))]; + } + + public IReadOnlyList ListTemplates() + { + return [.. _repo.ListMarkdownFiles() + .Where(path => EntryFileNaming.IsTemplateFileName(_repo.GetFileName(path))) + .Select(path => new EntryListItem( + FileName: _repo.GetFileName(path), + FilePath: _repo.GetFullPath(path)))]; + } + + public EntryLoadResult LoadEntry(string filePath) + { + var normalizedPath = _repo.GetFullPath(filePath); + if (!_repo.FileExists(normalizedPath)) + throw new FileNotFoundException($"Entry file not found: {normalizedPath}"); + + var rawContent = HtmlSanitizer.StripRichHtml(_repo.ReadFile(normalizedPath)); + var fileStem = _repo.GetFileNameWithoutExtension(normalizedPath); + var entry = JournalParser.ParseJournalContent(rawContent, fileStem); + + return new EntryLoadResult( + FileName: _repo.GetFileName(normalizedPath), + FilePath: normalizedPath, + Entry: entry.ToDto()); + } + + public EntryTemplateLoadResult LoadTemplate(string filePath) + { + var normalizedPath = _repo.GetFullPath(filePath); + if (!_repo.FileExists(normalizedPath)) + throw new FileNotFoundException($"Template file not found: {normalizedPath}"); + + var fileName = _repo.GetFileName(normalizedPath); + if (!EntryFileNaming.IsTemplateFileName(fileName)) + throw new ArgumentException("Template file name must end with .template.md."); + + var rawContent = HtmlSanitizer.StripRichHtml(_repo.ReadFile(normalizedPath)); + return new EntryTemplateLoadResult(fileName, normalizedPath, rawContent); + } + + public EntrySaveResult SaveEntry(EntrySavePayload payload) + { + var targetPath = ResolveTargetPath(payload.FilePath, payload.FileName); + var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim(); + var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? ""); + _repo.EnsureDirectory(targetPath); + + if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase)) + { + _repo.WriteFile(targetPath, sanitizedContent); + return new EntrySaveResult(targetPath); + } + + if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase)) + { + _repo.AppendFile(targetPath, "\n\n" + sanitizedContent.Trim()); + return new EntrySaveResult(targetPath); + } + + string finalContent; + if (_repo.FileExists(targetPath)) + { + var existingContent = _repo.ReadFile(targetPath); + var fileStem = _repo.GetFileNameWithoutExtension(targetPath); + var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem); + var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem); + existingEntry.MergeWith(newEntryData); + finalContent = existingEntry.ToMarkdown(); + } + else + { + finalContent = sanitizedContent; + } + + _repo.WriteFile(targetPath, finalContent); + return new EntrySaveResult(targetPath); + } + + public EntrySaveResult SaveTemplate(EntryTemplateSavePayload payload) + { + ArgumentNullException.ThrowIfNull(payload); + if (string.IsNullOrWhiteSpace(payload.Name)) + throw new ArgumentException("Template name is required."); + + var targetPath = ResolveTemplatePath(payload.FilePath, payload.Name); + var fileName = _repo.GetFileName(targetPath); + if (!EntryFileNaming.IsTemplateFileName(fileName)) + throw new ArgumentException("Template file name must end with .template.md."); + + var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? ""); + _repo.EnsureDirectory(targetPath); + _repo.WriteFile(targetPath, sanitizedContent); + return new EntrySaveResult(targetPath); + } + + public bool DeleteEntry(string filePath) + { + var normalizedPath = _repo.GetFullPath(filePath); + if (!_repo.FileExists(normalizedPath)) + return false; + _repo.DeleteFile(normalizedPath); + return true; + } + + public bool DeleteTemplate(string filePath) + { + var normalizedPath = _repo.GetFullPath(filePath); + if (!_repo.FileExists(normalizedPath)) + return false; + + var fileName = _repo.GetFileName(normalizedPath); + if (!EntryFileNaming.IsTemplateFileName(fileName)) + return false; + + _repo.DeleteFile(normalizedPath); + return true; + } + + private string ResolveTargetPath(string? filePath, string? fileName) + { + if (!string.IsNullOrWhiteSpace(filePath)) + return _repo.GetFullPath(filePath); + + var name = !string.IsNullOrWhiteSpace(fileName) + ? SanitizeFileName(fileName) + : $"{DateTime.Now:yyyy-MM-dd}"; + + return _repo.GetFullPath($"{name}.md"); + } + + private string ResolveTemplatePath(string? filePath, string templateName) + { + if (!string.IsNullOrWhiteSpace(filePath)) + return _repo.GetFullPath(filePath); + + var name = SanitizeFileName(templateName); + return _repo.GetFullPath($"{name}{EntryFileNaming.TemplateSuffix}"); + } + + private static string SanitizeFileName(string name) + { + var trimmed = name.Trim(); + if (trimmed.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed[..^3]; + + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(trimmed.Select(c => Array.IndexOf(invalid, c) >= 0 ? '_' : c).ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "untitled" : sanitized; + } +} diff --git a/Journal.Core/Services/Entries/EntrySearchService.cs b/Journal.Core/Services/Entries/EntrySearchService.cs new file mode 100644 index 0000000..1422765 --- /dev/null +++ b/Journal.Core/Services/Entries/EntrySearchService.cs @@ -0,0 +1,210 @@ +using Journal.Core.Dtos; +using Journal.Core.Repositories; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Journal.Core.Services.Entries; + +public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService +{ + private readonly IEntryFileRepository _repo = repo; + private readonly Lock _cacheLock = new(); + private readonly Dictionary _entryCache = new(StringComparer.Ordinal); + + public Task> SearchEntriesAsync(EntrySearchRequestDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var hasQuery = !string.IsNullOrWhiteSpace(request.Query); + var query = request.Query?.Trim() ?? ""; + var hasSectionFilter = !string.IsNullOrWhiteSpace(request.Section); + var section = request.Section?.Trim() ?? ""; + + var typeSet = NormalizeSet(request.Types); + var tagSet = NormalizeSet(request.Tags); + var checkedSet = NormalizeSet(request.Checked); + var uncheckedSet = NormalizeSet(request.Unchecked); + var hasFragmentFilters = typeSet.Count > 0 || tagSet.Count > 0; + var hasCheckboxFilters = checkedSet.Count > 0 || uncheckedSet.Count > 0; + + var startDate = ParseOptionalDate(request.StartDate, nameof(request.StartDate)); + var endDate = ParseOptionalDate(request.EndDate, nameof(request.EndDate)); + if (startDate.HasValue && endDate.HasValue && startDate.Value > endDate.Value) + throw new ArgumentException("startDate cannot be after endDate."); + + var currentFiles = _repo.ListMarkdownFiles() + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + .ToArray(); + + var currentFileSet = new HashSet(currentFiles, StringComparer.Ordinal); + var results = new List(); + foreach (var filePath in currentFiles) + { + var fileName = _repo.GetFileName(filePath); + if (EntryFileNaming.IsTemplateFileName(fileName)) + continue; + + var cached = GetOrBuildCachedEntry(filePath); + var entry = cached.Result.Entry; + + if (startDate.HasValue || endDate.HasValue) + { + if (!DateOnly.TryParseExact(entry.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var entryDate)) + continue; + + if (startDate.HasValue && entryDate < startDate.Value) + continue; + if (endDate.HasValue && entryDate > endDate.Value) + continue; + } + + var contentMatch = true; + if (hasQuery) + { + var haystack = hasSectionFilter ? GetSection(entry, section) : entry.RawContent; + contentMatch = haystack.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; + } + if (!contentMatch) + continue; + + var fragmentMatch = !hasFragmentFilters || entry.Fragments.Any(fragment => + (typeSet.Count == 0 || typeSet.Contains(fragment.Type)) && + (tagSet.Count == 0 || fragment.Tags.Any(tagSet.Contains))); + if (!fragmentMatch) + continue; + + var checkboxMatch = !hasCheckboxFilters || entry.Sections.Values.Any(sectionValue => + sectionValue.Checkboxes.Any(checkbox => + (checkedSet.Count > 0 && checkbox.Value && checkedSet.Contains(checkbox.Key)) || + (uncheckedSet.Count > 0 && !checkbox.Value && uncheckedSet.Contains(checkbox.Key)))); + if (!checkboxMatch) + continue; + + results.Add(cached.Result); + } + + RemoveStaleCacheEntries(currentFileSet); + + return Task.FromResult>(results); + } + + private CachedEntry GetOrBuildCachedEntry(string filePath) + { + var diskSignature = TryGetDiskFileSignature(filePath); + + if (diskSignature is not null) + { + lock (_cacheLock) + { + if (_entryCache.TryGetValue(filePath, out var cached) && + cached.Signature == diskSignature.Value) + { + return cached; + } + } + } + + var fileName = _repo.GetFileName(filePath); + var fileStem = _repo.GetFileNameWithoutExtension(filePath); + var rawContent = _repo.ReadFile(filePath); + var signature = diskSignature ?? BuildContentSignature(rawContent); + + lock (_cacheLock) + { + if (_entryCache.TryGetValue(filePath, out var cached) && + cached.Signature == signature) + { + return cached; + } + } + + var entry = JournalParser.ParseJournalContent(rawContent, fileStem).ToDto(); + var built = new CachedEntry(signature, new EntrySearchResultDto(fileName, entry)); + + lock (_cacheLock) + { + _entryCache[filePath] = built; + } + + return built; + } + + private static FileSignature? TryGetDiskFileSignature(string filePath) + { + if (filePath.StartsWith("db://", StringComparison.OrdinalIgnoreCase)) + return null; + if (!File.Exists(filePath)) + return null; + + var info = new FileInfo(filePath); + return new FileSignature(info.Length, info.LastWriteTimeUtc.Ticks, null); + } + + private static FileSignature BuildContentSignature(string content) + { + var bytes = Encoding.UTF8.GetBytes(content ?? ""); + var hash = Convert.ToHexString(SHA256.HashData(bytes)); + return new FileSignature(bytes.Length, 0, hash); + } + + private void RemoveStaleCacheEntries(HashSet currentFileSet) + { + lock (_cacheLock) + { + if (_entryCache.Count == 0) + return; + + var staleKeys = _entryCache.Keys + .Where(path => !currentFileSet.Contains(path)) + .ToArray(); + + foreach (var key in staleKeys) + _entryCache.Remove(key); + } + } + + private static string GetSection(JournalEntryDto entry, string sectionTitle) + { + if (string.IsNullOrWhiteSpace(sectionTitle)) + return ""; + + foreach (var (key, value) in entry.Sections) + { + if (string.Equals(key, sectionTitle, StringComparison.OrdinalIgnoreCase)) + return string.Join("\n", value.Content); + } + + return ""; + } + + private static HashSet NormalizeSet(IReadOnlyList? values) + { + if (values is null || values.Count == 0) + return []; + + var set = new HashSet(StringComparer.Ordinal); + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + set.Add(value.Trim()); + } + + return set; + } + + private static DateOnly? ParseOptionalDate(string? raw, string argumentName) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (DateOnly.TryParseExact(raw.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + return date; + + throw new ArgumentException($"Invalid {argumentName} value. Expected yyyy-MM-dd."); + } + + private readonly record struct FileSignature(long Length, long LastWriteUtcTicks, string? ContentHash); + private sealed record CachedEntry(FileSignature Signature, EntrySearchResultDto Result); +} diff --git a/Journal.Core/Services/Entries/HtmlSanitizer.cs b/Journal.Core/Services/Entries/HtmlSanitizer.cs new file mode 100644 index 0000000..0057e83 --- /dev/null +++ b/Journal.Core/Services/Entries/HtmlSanitizer.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace Journal.Core.Services.Entries; + +public static partial class HtmlSanitizer +{ + [GeneratedRegex("<(script|style)\\b[^>]*>.*?", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex ScriptStyleRegex(); + + [GeneratedRegex("", RegexOptions.IgnoreCase)] + private static partial Regex BrTagRegex(); + + [GeneratedRegex("", RegexOptions.IgnoreCase)] + private static partial Regex BlockEndTagRegex(); + + [GeneratedRegex("]*>", RegexOptions.IgnoreCase)] + private static partial Regex LiStartTagRegex(); + + [GeneratedRegex("", RegexOptions.IgnoreCase)] + private static partial Regex LiEndTagRegex(); + + [GeneratedRegex("<(td|th)\\b[^>]*>", RegexOptions.IgnoreCase)] + private static partial Regex CellStartTagRegex(); + + [GeneratedRegex("", RegexOptions.IgnoreCase)] + private static partial Regex CellEndTagRegex(); + + [GeneratedRegex("]*>", RegexOptions.IgnoreCase)] + private static partial Regex HrTagRegex(); + + [GeneratedRegex("<[^>]+>", RegexOptions.Singleline)] + private static partial Regex AllTagsRegex(); + + [GeneratedRegex("[ \\t]{2,}")] + private static partial Regex MultipleSpacesRegex(); + + [GeneratedRegex("\n{3,}")] + private static partial Regex MultipleNewlinesRegex(); + + [GeneratedRegex("]*>")] + private static partial Regex HtmlTagCountRegex(); + public static string StripRichHtml(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return content; + if (!LooksLikeRichHtml(content)) + return content; + + var text = content.Replace("\r\n", "\n").Replace("\r", "\n"); + text = ScriptStyleRegex().Replace(text, ""); + text = BrTagRegex().Replace(text, "\n"); + text = BlockEndTagRegex().Replace(text, "\n"); + text = LiStartTagRegex().Replace(text, "\n- "); + text = LiEndTagRegex().Replace(text, "\n"); + text = CellStartTagRegex().Replace(text, " | "); + text = CellEndTagRegex().Replace(text, " "); + text = HrTagRegex().Replace(text, "\n---\n"); + text = AllTagsRegex().Replace(text, ""); + text = WebUtility.HtmlDecode(text) + .Replace('\u00a0', ' ') + .Replace("\u200b", "", StringComparison.Ordinal); + text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd())); + text = MultipleSpacesRegex().Replace(text, " "); + text = MultipleNewlinesRegex().Replace(text, "\n\n").Trim(); + return string.IsNullOrEmpty(text) ? content : text; + } + + public static bool LooksLikeRichHtml(string content) + { + var lowered = content.ToLowerInvariant(); + string[] markers = + [ + "", " lowered.Contains(marker, StringComparison.Ordinal))) + return true; + return HtmlTagCountRegex().Count(lowered) >= 8; + } +} diff --git a/Journal.Core/Services/Entries/IEntryFileService.cs b/Journal.Core/Services/Entries/IEntryFileService.cs new file mode 100644 index 0000000..cbf5645 --- /dev/null +++ b/Journal.Core/Services/Entries/IEntryFileService.cs @@ -0,0 +1,15 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Entries; + +public interface IEntryFileService +{ + IReadOnlyList ListEntries(); + IReadOnlyList ListTemplates(); + EntryLoadResult LoadEntry(string filePath); + EntryTemplateLoadResult LoadTemplate(string filePath); + EntrySaveResult SaveEntry(EntrySavePayload payload); + EntrySaveResult SaveTemplate(EntryTemplateSavePayload payload); + bool DeleteEntry(string filePath); + bool DeleteTemplate(string filePath); +} diff --git a/Journal.Core/Services/Entries/IEntrySearchService.cs b/Journal.Core/Services/Entries/IEntrySearchService.cs new file mode 100644 index 0000000..6a2c24a --- /dev/null +++ b/Journal.Core/Services/Entries/IEntrySearchService.cs @@ -0,0 +1,8 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Entries; + +public interface IEntrySearchService +{ + Task> SearchEntriesAsync(EntrySearchRequestDto request); +} diff --git a/Journal.Core/Services/Entries/JournalEntryDtoMapper.cs b/Journal.Core/Services/Entries/JournalEntryDtoMapper.cs new file mode 100644 index 0000000..1cce1d5 --- /dev/null +++ b/Journal.Core/Services/Entries/JournalEntryDtoMapper.cs @@ -0,0 +1,32 @@ +using Journal.Core.Dtos; +using Journal.Core.Models; + +namespace Journal.Core.Services.Entries; + +internal static class JournalEntryDtoMapper +{ + public static JournalEntryDto ToDto(this JournalEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return new JournalEntryDto( + Date: entry.Date, + Fragments: + [ + .. entry.Fragments.Select(fragment => new FragmentDto( + Id: fragment.Id, + Type: fragment.Type, + Description: fragment.Description, + Time: fragment.Time, + Tags: [.. fragment.Tags])) + ], + RawContent: entry.RawContent, + Sections: entry.Sections.ToDictionary( + section => section.Key, + section => new ParsedSectionDto( + Title: section.Value.Title, + Content: [.. section.Value.Content], + Checkboxes: section.Value.Checkboxes.ToDictionary(checkbox => checkbox.Key, checkbox => checkbox.Value)), + StringComparer.Ordinal)); + } +} diff --git a/Journal.Core/Services/Entries/JournalParser.cs b/Journal.Core/Services/Entries/JournalParser.cs new file mode 100644 index 0000000..7f74811 --- /dev/null +++ b/Journal.Core/Services/Entries/JournalParser.cs @@ -0,0 +1,175 @@ +using System.Text.RegularExpressions; +using Journal.Core.Models; + +namespace Journal.Core.Services.Entries; + +public static partial class JournalParser +{ + [GeneratedRegex(@"(?:\*\*Date:\*\*|\*\*Date:|Date:)\s*(.+)")] + private static partial Regex DatePattern(); + [GeneratedRegex(@"^\#\#+\s*(.*)$")] + private static partial Regex SectionHeaderPattern(); + [GeneratedRegex(@"^\s*[-*]\s*\[([xX ])\]\s*(.*)$")] + private static partial Regex CheckboxPattern(); + [GeneratedRegex(@"^(!\w+)\s*((?:@\S+\s*)?)(?:\s*((?:#\S+\s*)*))?\s*$")] + private static partial Regex FragmentHeaderPattern(); + [GeneratedRegex(@"^!\w+\s*")] + private static partial Regex FragmentBoundaryPattern(); + + public static JournalEntry ParseJournalContent(string content, string fileStem) + { + ArgumentNullException.ThrowIfNull(content); + return new JournalEntry( + date: ExtractDate(content, fileStem), + rawContent: content, + sections: ParseSections(content), + fragments: ParseFragments(content)); + } + + public static string ExtractDate(string content, string fileStem) + { + ArgumentNullException.ThrowIfNull(content); + if (string.IsNullOrWhiteSpace(fileStem)) + throw new ArgumentException("File stem is required", nameof(fileStem)); + + var match = DatePattern().Match(content); + if (match.Success) + { + var parsed = match.Groups[1].Value.Trim(); + if (!string.IsNullOrWhiteSpace(parsed)) + return parsed; + } + + return fileStem.Trim(); + } + + public static Dictionary ParseSections(string content) + { + ArgumentNullException.ThrowIfNull(content); + + var parsedSections = new Dictionary(); + string? currentSectionTitle = null; + var currentSectionContent = new List(); + var currentSectionCheckboxes = new Dictionary(); + + var lines = content.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + foreach (var line in lines) + { + var sectionHeaderMatch = SectionHeaderPattern().Match(line.Trim()); + if (sectionHeaderMatch.Success) + { + if (currentSectionTitle is not null) + { + parsedSections[currentSectionTitle] = new ParsedSection( + currentSectionTitle, + currentSectionContent, + currentSectionCheckboxes); + } + + var headerText = sectionHeaderMatch.Groups[1].Value.Trim(); + var foundTitle = FindCanonicalSectionTitle(headerText); + + if (foundTitle is not null) + { + currentSectionTitle = foundTitle; + currentSectionContent = []; + currentSectionCheckboxes = []; + } + else + { + currentSectionTitle = null; + currentSectionContent = []; + currentSectionCheckboxes = []; + } + + continue; + } + + if (currentSectionTitle is not null) + { + var checkboxMatch = CheckboxPattern().Match(line); + if (checkboxMatch.Success) + { + var isChecked = checkboxMatch.Groups[1].Value.Trim().Equals("x", StringComparison.OrdinalIgnoreCase); + var checkboxText = checkboxMatch.Groups[2].Value.Trim(); + currentSectionCheckboxes[checkboxText] = isChecked; + } + + currentSectionContent.Add(line); + } + } + + if (currentSectionTitle is not null) + { + parsedSections[currentSectionTitle] = new ParsedSection( + currentSectionTitle, + currentSectionContent, + currentSectionCheckboxes); + } + + return parsedSections; + } + + public static List ParseFragments(string content) + { + ArgumentNullException.ThrowIfNull(content); + + var fragments = new List(); + var lines = content.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + + for (var i = 0; i < lines.Length; i++) + { + var headerMatch = FragmentHeaderPattern().Match(lines[i]); + if (!headerMatch.Success) + continue; + + var type = headerMatch.Groups[1].Value.Trim(); + var timeToken = headerMatch.Groups[2].Value.Trim().TrimStart('@'); + var tagsToken = headerMatch.Groups[3].Value.Trim(); + + var descriptionLines = new List(); + var j = i + 1; + while (j < lines.Length && !FragmentBoundaryPattern().IsMatch(lines[j])) + { + descriptionLines.Add(lines[j]); + j++; + } + + var description = string.Join("\n", descriptionLines).Trim(); + if (!string.IsNullOrWhiteSpace(description)) + { + var fragment = new Fragment(type, description); + if (!string.IsNullOrWhiteSpace(timeToken) && DateTimeOffset.TryParse(timeToken, out var parsedTime)) + fragment.Time = parsedTime; + + if (!string.IsNullOrWhiteSpace(tagsToken)) + { + fragment.Tags = + [ + .. tagsToken.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(t => t.StartsWith('#')) + .Select(t => t.Trim().TrimStart('#')) + .Where(t => !string.IsNullOrWhiteSpace(t)) + ]; + } + + fragments.Add(fragment); + } + + i = j - 1; + } + + return fragments; + } + + private static string? FindCanonicalSectionTitle(string headerText) + { + foreach (var title in SectionTitles.Canonical) + { + if (headerText.Contains(title, StringComparison.OrdinalIgnoreCase)) + return title; + } + + return null; + } +} diff --git a/Journal.Core/Services/Fragments/FragmentService.cs b/Journal.Core/Services/Fragments/FragmentService.cs new file mode 100644 index 0000000..55917fa --- /dev/null +++ b/Journal.Core/Services/Fragments/FragmentService.cs @@ -0,0 +1,81 @@ +using System.ComponentModel.DataAnnotations; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Fragments; + +public class FragmentService(IFragmentRepository repo) : IFragmentService +{ + private readonly IFragmentRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + private static FragmentDto Map(Fragment f) => new( + f.Id, + f.Type, + f.Description, + f.Time, + f.Tags != null ? [.. f.Tags] : [] + ); + + public FragmentDto Create(CreateFragmentDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + var f = new Fragment(dto.Type, dto.Description); + if (dto.Tags != null) + f.Tags = [.. dto.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim())]; + + _repo.Add(f); + return Map(f); + } + + public bool Update(Guid id, UpdateFragmentDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Type != null && string.IsNullOrWhiteSpace(dto.Type)) + throw new ValidationException("Type cannot be empty"); + if (dto.Description != null && string.IsNullOrWhiteSpace(dto.Description)) + throw new ValidationException("Description cannot be empty"); + + var type = dto.Type?.Trim(); + var description = dto.Description?.Trim(); + var tags = dto.Tags?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t!.Trim()).ToList(); + + return _repo.Update(id, type, description, tags, dto.Time); + } + + public bool Remove(Guid id) => _repo.Remove(id); + + public List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + { + var items = _repo.Search(type, tag, timeAfter); + return [.. items.Select(Map)]; + } + + public List GetByTag(string tag) + { + var items = _repo.GetByTag(tag); + return [.. items.Select(Map)]; + } + + public List GetByType(string type) + { + var items = _repo.GetByType(type); + return [.. items.Select(Map)]; + } + + public List GetAll() + { + var items = _repo.GetAll(); + return [.. items.Select(Map)]; + } + + public FragmentDto? GetById(Guid id) + { + var f = _repo.GetById(id); + return f is null ? null : Map(f); + } +} diff --git a/Journal.Core/Services/Fragments/IFragmentService.cs b/Journal.Core/Services/Fragments/IFragmentService.cs new file mode 100644 index 0000000..6087081 --- /dev/null +++ b/Journal.Core/Services/Fragments/IFragmentService.cs @@ -0,0 +1,15 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Fragments; + +public interface IFragmentService +{ + FragmentDto Create(CreateFragmentDto dto); + bool Update(Guid id, UpdateFragmentDto dto); + bool Remove(Guid id); + List Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); + List GetByTag(string tag); + List GetByType(string type); + List GetAll(); + FragmentDto? GetById(Guid id); +} diff --git a/Journal.Core/Services/Lists/IListService.cs b/Journal.Core/Services/Lists/IListService.cs new file mode 100644 index 0000000..b727bb9 --- /dev/null +++ b/Journal.Core/Services/Lists/IListService.cs @@ -0,0 +1,12 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Lists; + +public interface IListService +{ + List GetAll(); + ListDocumentDto? GetById(Guid id); + ListDocumentDto Create(CreateListDto dto); + bool Update(Guid id, UpdateListDto dto); + bool Remove(Guid id); +} diff --git a/Journal.Core/Services/Lists/ListService.cs b/Journal.Core/Services/Lists/ListService.cs new file mode 100644 index 0000000..8b425a7 --- /dev/null +++ b/Journal.Core/Services/Lists/ListService.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Lists; + +public class ListService(IListRepository repo) : IListService +{ + private readonly IListRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + private static ListDocumentDto Map(ListDocument d) => new( + d.Id, + d.Label, + d.Content, + d.CreatedAt, + d.UpdatedAt + ); + + public List GetAll() + { + var items = _repo.GetAll(); + return [.. items.Select(Map)]; + } + + public ListDocumentDto? GetById(Guid id) + { + var d = _repo.GetById(id); + return d is null ? null : Map(d); + } + + public ListDocumentDto Create(CreateListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + var doc = new ListDocument(dto.Label, dto.Content ?? ""); + _repo.Add(doc); + return Map(doc); + } + + public bool Update(Guid id, UpdateListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Label is not null && string.IsNullOrWhiteSpace(dto.Label)) + throw new ValidationException("Label cannot be empty"); + + return _repo.Update(id, dto.Label?.Trim(), dto.Content); + } + + public bool Remove(Guid id) => _repo.Remove(id); +} diff --git a/Journal.Core/Services/Logging/CommandLogger.cs b/Journal.Core/Services/Logging/CommandLogger.cs new file mode 100644 index 0000000..018dd5f --- /dev/null +++ b/Journal.Core/Services/Logging/CommandLogger.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text.Json; + +namespace Journal.Core.Services.Logging; + +public sealed class CommandLogger +{ + public static void LogStart(string action, string correlationId, JsonElement? payload) + { + var redactedPayload = LogRedactor.RedactPayload(payload); + EmitLog("information", action, correlationId, "start", redactedPayload); + } + + public static void LogSuccess(string action, string correlationId) => EmitLog("information", action, correlationId, "success"); + + public static void LogFailure(string action, string correlationId, string errorType, string? message = null) + { + var details = string.IsNullOrWhiteSpace(message) + ? "" + : (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)"); + EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details); + } + + private static void EmitLog( + string level, + string action, + string correlationId, + string outcome, + object? payload = null, + string? errorType = null, + string? details = null) + { + if (!ShouldLog(level)) + return; + + var envelope = new + { + timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), + level, + component = "Entry", + action, + correlation_id = correlationId, + outcome, + error_type = errorType, + details, + payload + }; + Console.Error.WriteLine(JsonSerializer.Serialize(envelope)); + } + + private static bool ShouldLog(string level) + { + var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning") + .Trim() + .ToLowerInvariant(); + var configuredRank = LogLevelRank(configured); + var incomingRank = LogLevelRank(level); + return incomingRank >= configuredRank; + } + + private static int LogLevelRank(string level) => level switch + { + "trace" => 0, + "debug" => 1, + "information" => 2, + "info" => 2, + "warning" => 3, + "warn" => 3, + "error" => 4, + "critical" => 5, + _ => 3 + }; +} diff --git a/Journal.Core/Services/Logging/LogRedactor.cs b/Journal.Core/Services/Logging/LogRedactor.cs new file mode 100644 index 0000000..b116bb5 --- /dev/null +++ b/Journal.Core/Services/Logging/LogRedactor.cs @@ -0,0 +1,73 @@ +using System.Text.Json; + +namespace Journal.Core.Services.Logging; + +public static class LogRedactor +{ + private static readonly HashSet SensitiveKeys = new(StringComparer.OrdinalIgnoreCase) + { + "password", + "passphrase", + "secret", + "token", + "apiKey", + "api_key", + "cloudAiApiKey", + "content", + "rawContent", + "prompt", + "audioBase64", + "audio_base64", + "text" + }; + + public static object? RedactPayload(JsonElement? payload) + { + if (payload is null) + return null; + return RedactElement(payload.Value, parentKey: null); + } + + private static object? RedactElement(JsonElement element, string? parentKey) + { + if (parentKey is not null && SensitiveKeys.Contains(parentKey)) + return "[REDACTED]"; + + return element.ValueKind switch + { + JsonValueKind.Object => RedactObject(element), + JsonValueKind.Array => RedactArray(element), + JsonValueKind.String => RedactString(element.GetString() ?? "", parentKey), + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + } + + private static Dictionary RedactObject(JsonElement element) + { + var output = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in element.EnumerateObject()) + output[property.Name] = RedactElement(property.Value, property.Name); + return output; + } + + private static List RedactArray(JsonElement element) + { + var output = new List(); + foreach (var item in element.EnumerateArray()) + output.Add(RedactElement(item, parentKey: null)); + return output; + } + + private static object RedactString(string value, string? key) + { + if (key is not null && SensitiveKeys.Contains(key)) + return "[REDACTED]"; + if (value.Length <= 128) + return value; + return $"{value[..128]}...(truncated)"; + } +} diff --git a/Journal.Core/Services/Sidecar/SidecarCli.cs b/Journal.Core/Services/Sidecar/SidecarCli.cs new file mode 100644 index 0000000..dd2f29d --- /dev/null +++ b/Journal.Core/Services/Sidecar/SidecarCli.cs @@ -0,0 +1,380 @@ +using System.Text; +using Journal.Core.Dtos; +using Journal.Core.Services.Config; +using Journal.Core.Services.Entries; +using Journal.Core.Services.Vault; + +namespace Journal.Core.Services.Sidecar; + +public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config, IEntryFileService entryFiles) +{ + private readonly IVaultStorageService _vaultStorage = vaultStorage; + private readonly IEntrySearchService _entrySearch = entrySearch; + private readonly IJournalConfigService _config = config; + private readonly IEntryFileService _entryFiles = entryFiles; + + public async Task RunAsync(string[] args, Entry entry) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(entry); + + if (args.Length == 0) + { + await entry.RunAsync(); + return 0; + } + + if (IsHelp(args[0])) + { + PrintUsage(); + return 0; + } + + if (string.Equals(args[0], "vault", StringComparison.OrdinalIgnoreCase)) + return RunVaultCommand([.. args.Skip(1)]); + if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase)) + return RunSearchCommand([.. args.Skip(1)]); + + Console.Error.WriteLine($"Unknown command: {args[0]}"); + PrintUsage(); + return 2; + } + + public int RunVaultCommand(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (args.Length == 0 || IsHelp(args[0])) + { + PrintVaultUsage(); + return 2; + } + + var action = args[0].Trim().ToLowerInvariant(); + if (action is not ("load" or "save")) + { + Console.Error.WriteLine($"Unknown vault action: {args[0]}"); + PrintVaultUsage(); + return 2; + } + + if (!TryParseVaultOptions([.. args.Skip(1)], out var options, out var parseError)) + { + Console.Error.WriteLine(parseError); + PrintVaultUsage(); + return 2; + } + + var password = options.Password; + if (string.IsNullOrWhiteSpace(password)) + password = PromptPassword(); + + if (string.IsNullOrWhiteSpace(password)) + { + Console.Error.WriteLine("Vault password cannot be empty."); + return 2; + } + + var vaultDirectory = ResolveVaultDirectory(options.VaultDirectory); + + try + { + if (action == "load") + { + var ok = _vaultStorage.LoadAllVaults(password, vaultDirectory, ResolveVaultStorageDirectory()); + if (!ok) + { + Console.Error.WriteLine("Incorrect password."); + return 1; + } + + Console.WriteLine("Vault loaded."); + return 0; + } + + _vaultStorage.RebuildAllVaults(password, vaultDirectory, ResolveVaultStorageDirectory()); + Console.WriteLine("Vault saved."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Vault command failed: {ex.Message}"); + return 1; + } + } + + public int RunSearchCommand(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (args.Length > 0 && IsHelp(args[0])) + { + PrintSearchUsage(); + return 0; + } + + if (!TryParseSearchOptions(args, out var options, out var parseError)) + { + Console.Error.WriteLine(parseError); + PrintSearchUsage(); + return 2; + } + + var entryCount = _entryFiles.ListEntries().Count; + if (entryCount == 0) + { + Console.WriteLine("No journal entries found. Please load the vault first: journal vault load"); + return 0; + } + + try + { + var request = new EntrySearchRequestDto( + Query: options.Query, + Section: options.Section, + StartDate: options.StartDate, + EndDate: options.EndDate, + Tags: options.Tags, + Types: options.Types, + Checked: options.Checked, + Unchecked: options.Unchecked); + + var results = _entrySearch.SearchEntriesAsync(request).GetAwaiter().GetResult(); + if (results.Count == 0) + { + Console.WriteLine("No entries found matching the criteria."); + return 0; + } + + foreach (var result in results) + { + Console.WriteLine($"--- {result.Entry.Date} ---"); + Console.WriteLine(result.Entry.RawContent); + Console.WriteLine(); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Search command failed: {ex.Message}"); + return 1; + } + } + + private static bool TryParseVaultOptions(string[] args, out VaultOptions options, out string error) + { + var parsed = new VaultOptions(); + for (var i = 0; i < args.Length; i++) + { + var token = args[i]; + if (IsHelp(token)) + { + options = parsed; + error = ""; + return false; + } + + if (i + 1 >= args.Length) + { + options = parsed; + error = $"Missing value for option '{token}'."; + return false; + } + + var value = args[i + 1]; + switch (token) + { + case "--password": + case "-p": + parsed.Password = value; + break; + case "--vault-dir": + parsed.VaultDirectory = value; + break; + default: + options = parsed; + error = $"Unknown option '{token}'."; + return false; + } + + i++; + } + + options = parsed; + error = ""; + return true; + } + + private static bool TryParseSearchOptions(string[] args, out SearchOptions options, out string error) + { + var parsed = new SearchOptions(); + for (var i = 0; i < args.Length; i++) + { + var token = args[i]; + if (IsHelp(token)) + { + options = parsed; + error = ""; + return false; + } + + if (!token.StartsWith('-')) + { + if (parsed.Query is null) + { + parsed.Query = token; + continue; + } + + options = parsed; + error = $"Unexpected positional argument '{token}'."; + return false; + } + + if (i + 1 >= args.Length) + { + options = parsed; + error = $"Missing value for option '{token}'."; + return false; + } + + var value = args[i + 1]; + switch (token) + { + case "--tag": + case "-t": + parsed.Tags.Add(value); + break; + case "--type": + case "-y": + parsed.Types.Add(value); + break; + case "--start-date": + case "-s": + parsed.StartDate = value; + break; + case "--end-date": + case "-e": + parsed.EndDate = value; + break; + case "--section": + case "-sec": + parsed.Section = value; + break; + case "--checked": + case "-chk": + parsed.Checked.Add(value); + break; + case "--unchecked": + case "-uchk": + parsed.Unchecked.Add(value); + break; + default: + options = parsed; + error = $"Unknown option '{token}'."; + return false; + } + + i++; + } + + options = parsed; + error = ""; + return true; + } + + private string ResolveVaultDirectory(string? vaultOverride) + { + var envVault = Environment.GetEnvironmentVariable("JOURNAL_VAULT_DIR"); + var defaults = _config.Current; + + var vault = FirstNonEmpty(vaultOverride, envVault) ?? defaults.VaultDirectory; + return Path.GetFullPath(vault); + } + + private string ResolveVaultStorageDirectory() + { + var dbDirOverride = Environment.GetEnvironmentVariable("JOURNAL_DATABASE_DIR"); + if (!string.IsNullOrWhiteSpace(dbDirOverride)) + return Path.GetFullPath(dbDirOverride); + + return Path.GetFullPath(Path.Combine(_config.Current.VaultDirectory, "db")); + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + + private static string PromptPassword() + { + if (Console.IsInputRedirected) + return Console.ReadLine() ?? ""; + + Console.Write("Vault password: "); + var builder = new StringBuilder(); + while (true) + { + var keyInfo = Console.ReadKey(intercept: true); + if (keyInfo.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + + if (keyInfo.Key == ConsoleKey.Backspace) + { + if (builder.Length > 0) + builder.Length--; + continue; + } + + if (!char.IsControl(keyInfo.KeyChar)) + builder.Append(keyInfo.KeyChar); + } + + return builder.ToString(); + } + + private static bool IsHelp(string token) => + string.Equals(token, "--help", StringComparison.OrdinalIgnoreCase) || + string.Equals(token, "-h", StringComparison.OrdinalIgnoreCase) || + string.Equals(token, "help", StringComparison.OrdinalIgnoreCase); + + private static void PrintUsage() + { + Console.WriteLine("Usage:"); + Console.WriteLine(" Journal.Sidecar # sidecar stdin/stdout mode"); + Console.WriteLine(" Journal.Sidecar vault load [--password ] [--vault-dir ]"); + Console.WriteLine(" Journal.Sidecar vault save [--password ] [--vault-dir ]"); + Console.WriteLine(" Journal.Sidecar search [query] [--tag ] [--type ] [--start-date ] [--end-date ] [--section ] [--checked <text>] [--unchecked <text>]"); + } + + private static void PrintVaultUsage() + { + Console.WriteLine("Vault usage:"); + Console.WriteLine(" Journal.Sidecar vault load [--password <value>] [--vault-dir <path>]"); + Console.WriteLine(" Journal.Sidecar vault save [--password <value>] [--vault-dir <path>]"); + } + + private static void PrintSearchUsage() + { + Console.WriteLine("Search usage:"); + Console.WriteLine(" Journal.Sidecar search [query] [--tag <value>] [--type <value>] [--start-date <yyyy-MM-dd>] [--end-date <yyyy-MM-dd>] [--section <title>] [--checked <text>] [--unchecked <text>]"); + } + + private sealed class VaultOptions + { + public string? Password { get; set; } + public string? VaultDirectory { get; set; } + } + + private sealed class SearchOptions + { + public string? Query { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } + public string? Section { get; set; } + public List<string> Tags { get; } = []; + public List<string> Types { get; } = []; + public List<string> Checked { get; } = []; + public List<string> Unchecked { get; } = []; + } +} diff --git a/Journal.Core/Services/Speech/DisabledS2TService.cs b/Journal.Core/Services/Speech/DisabledS2TService.cs new file mode 100644 index 0000000..73c3ed6 --- /dev/null +++ b/Journal.Core/Services/Speech/DisabledS2TService.cs @@ -0,0 +1,17 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Speech; + +public sealed class DisabledS2TService(string message = "S2T is disabled.") : IS2TService +{ + private readonly string _message = string.IsNullOrWhiteSpace(message) ? "S2T is disabled." : message.Trim(); + + public Task<S2TStartResultDto> StartAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new S2TStartResultDto(false, "stopped", _message)); + + public Task<S2TStopResultDto> StopAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new S2TStopResultDto(false, "stopped", _message)); + + public Task<S2TPollResultDto> PollAsync(int maxItems = 8, CancellationToken cancellationToken = default) + => Task.FromResult(new S2TPollResultDto([], false, "stopped", _message)); +} diff --git a/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs b/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs new file mode 100644 index 0000000..4f1959b --- /dev/null +++ b/Journal.Core/Services/Speech/DisabledSpeechBridgeService.cs @@ -0,0 +1,25 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Speech; + +public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService +{ + private readonly string _provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim(); + private readonly string _message = string.IsNullOrWhiteSpace(message) ? "Speech bridge is disabled." : message.Trim(); + + public Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default) + { + var warning = $"{_message} (provider={_provider})"; + return Task.FromResult(new SpeechDevicesResultDto([], warning)); + } + + public Task<SpeechTranscribeResultDto> TranscribeAsync( + SpeechTranscribeRequestDto request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var engine = string.IsNullOrWhiteSpace(request.Engine) ? "none" : request.Engine.Trim(); + var warning = $"{_message} (provider={_provider})"; + return Task.FromResult(new SpeechTranscribeResultDto("", engine, warning)); + } +} diff --git a/Journal.Core/Services/Speech/IS2TService.cs b/Journal.Core/Services/Speech/IS2TService.cs new file mode 100644 index 0000000..4b8fdfc --- /dev/null +++ b/Journal.Core/Services/Speech/IS2TService.cs @@ -0,0 +1,10 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Speech; + +public interface IS2TService +{ + Task<S2TStartResultDto> StartAsync(CancellationToken cancellationToken = default); + Task<S2TStopResultDto> StopAsync(CancellationToken cancellationToken = default); + Task<S2TPollResultDto> PollAsync(int maxItems = 8, CancellationToken cancellationToken = default); +} diff --git a/Journal.Core/Services/Speech/ISpeechBridgeService.cs b/Journal.Core/Services/Speech/ISpeechBridgeService.cs new file mode 100644 index 0000000..574ba78 --- /dev/null +++ b/Journal.Core/Services/Speech/ISpeechBridgeService.cs @@ -0,0 +1,9 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Speech; + +public interface ISpeechBridgeService +{ + Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default); + Task<SpeechTranscribeResultDto> TranscribeAsync(SpeechTranscribeRequestDto request, CancellationToken cancellationToken = default); +} diff --git a/Journal.Core/Services/Todos/ITodoService.cs b/Journal.Core/Services/Todos/ITodoService.cs new file mode 100644 index 0000000..9b14e8b --- /dev/null +++ b/Journal.Core/Services/Todos/ITodoService.cs @@ -0,0 +1,16 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Todos; + +public interface ITodoService +{ + List<TodoListDto> GetAllLists(); + TodoListDto? GetListById(Guid id); + TodoListDto CreateList(CreateTodoListDto dto); + bool UpdateList(Guid id, UpdateTodoListDto dto); + bool RemoveList(Guid id); + + TodoItemDto CreateItem(CreateTodoItemDto dto); + bool UpdateItem(Guid id, UpdateTodoItemDto dto); + bool RemoveItem(Guid id); +} diff --git a/Journal.Core/Services/Todos/TodoService.cs b/Journal.Core/Services/Todos/TodoService.cs new file mode 100644 index 0000000..9d1f587 --- /dev/null +++ b/Journal.Core/Services/Todos/TodoService.cs @@ -0,0 +1,91 @@ +using System.ComponentModel.DataAnnotations; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Todos; + +public class TodoService(ITodoRepository repo) : ITodoService +{ + private readonly ITodoRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + private static TodoItemDto MapItem(TodoItem i) => new( + i.Id, + i.ListId, + i.Text, + i.Done, + i.SortOrder + ); + + private TodoListDto MapList(TodoList l) + { + var items = _repo.GetItemsByListId(l.Id); + return new TodoListDto( + l.Id, + l.Label, + l.CreatedAt, + [.. items.Select(MapItem)] + ); + } + + public List<TodoListDto> GetAllLists() + { + var lists = _repo.GetAllLists(); + return [.. lists.Select(MapList)]; + } + + public TodoListDto? GetListById(Guid id) + { + var l = _repo.GetListById(id); + return l is null ? null : MapList(l); + } + + public TodoListDto CreateList(CreateTodoListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + var list = new TodoList(dto.Label); + _repo.AddList(list); + return new TodoListDto(list.Id, list.Label, list.CreatedAt, []); + } + + public bool UpdateList(Guid id, UpdateTodoListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Label is not null && string.IsNullOrWhiteSpace(dto.Label)) + throw new ValidationException("Label cannot be empty"); + + return _repo.UpdateList(id, dto.Label?.Trim()); + } + + public bool RemoveList(Guid id) => _repo.RemoveList(id); + + public TodoItemDto CreateItem(CreateTodoItemDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + if (dto.ListId == Guid.Empty) + throw new ValidationException("ListId is required"); + + var item = new TodoItem(dto.ListId, dto.Text, dto.SortOrder ?? 0); + _repo.AddItem(item); + return MapItem(item); + } + + public bool UpdateItem(Guid id, UpdateTodoItemDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Text is not null && string.IsNullOrWhiteSpace(dto.Text)) + throw new ValidationException("Text cannot be empty"); + + return _repo.UpdateItem(id, dto.Text?.Trim(), dto.Done, dto.SortOrder); + } + + public bool RemoveItem(Guid id) => _repo.RemoveItem(id); +} diff --git a/Journal.Core/Services/Vault/IVaultCryptoService.cs b/Journal.Core/Services/Vault/IVaultCryptoService.cs new file mode 100644 index 0000000..af9e388 --- /dev/null +++ b/Journal.Core/Services/Vault/IVaultCryptoService.cs @@ -0,0 +1,8 @@ +namespace Journal.Core.Services.Vault; + +public interface IVaultCryptoService +{ + byte[] DeriveKey(string password, byte[] salt); + byte[] EncryptData(byte[] data, string password); + byte[] DecryptData(byte[] encryptedData, string password); +} diff --git a/Journal.Core/Services/Vault/IVaultStorageService.cs b/Journal.Core/Services/Vault/IVaultStorageService.cs new file mode 100644 index 0000000..2a1b5f0 --- /dev/null +++ b/Journal.Core/Services/Vault/IVaultStorageService.cs @@ -0,0 +1,8 @@ +namespace Journal.Core.Services.Vault; + +public interface IVaultStorageService +{ + bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory); + void RebuildAllVaults(string password, string vaultDirectory, string dataDirectory); + void ClearDataDirectory(string dataDirectory); +} diff --git a/Journal.Core/Services/Vault/VaultCryptoService.cs b/Journal.Core/Services/Vault/VaultCryptoService.cs new file mode 100644 index 0000000..8ef4e96 --- /dev/null +++ b/Journal.Core/Services/Vault/VaultCryptoService.cs @@ -0,0 +1,83 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Journal.Core.Services.Vault; + +public class VaultCryptoService : IVaultCryptoService +{ + public const int SaltSize = 16; + public const int KeySize = 32; + public const int NonceSize = 12; + public const int TagSize = 16; + public const int Iterations = 600_000; + + public byte[] DeriveKey(string password, byte[] salt) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + ArgumentNullException.ThrowIfNull(salt); + if (salt.Length != SaltSize) + throw new ArgumentException($"Salt must be {SaltSize} bytes.", nameof(salt)); + + return Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + salt, + Iterations, + HashAlgorithmName.SHA256, + KeySize); + } + + public byte[] EncryptData(byte[] data, string password) + { + ArgumentNullException.ThrowIfNull(data); + + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + return EncryptData(data, password, salt, nonce); + } + + public byte[] DecryptData(byte[] encryptedData, string password) + { + ArgumentNullException.ThrowIfNull(encryptedData); + + var minLength = SaltSize + NonceSize + TagSize; + if (encryptedData.Length < minLength) + throw new ArgumentException("Encrypted payload is too short.", nameof(encryptedData)); + + var salt = encryptedData.AsSpan(0, SaltSize).ToArray(); + var nonce = encryptedData.AsSpan(SaltSize, NonceSize).ToArray(); + var tag = encryptedData.AsSpan(SaltSize + NonceSize, TagSize).ToArray(); + var ciphertext = encryptedData.AsSpan(SaltSize + NonceSize + TagSize).ToArray(); + + var key = DeriveKey(password, salt); + var plaintext = new byte[ciphertext.Length]; + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + return plaintext; + } + + public byte[] EncryptData(byte[] data, string password, byte[] salt, byte[] nonce) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(salt); + ArgumentNullException.ThrowIfNull(nonce); + if (salt.Length != SaltSize) + throw new ArgumentException($"Salt must be {SaltSize} bytes.", nameof(salt)); + if (nonce.Length != NonceSize) + throw new ArgumentException($"Nonce must be {NonceSize} bytes.", nameof(nonce)); + + var key = DeriveKey(password, salt); + var ciphertext = new byte[data.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(key, TagSize); + aes.Encrypt(nonce, data, ciphertext, tag); + + var payload = new byte[SaltSize + NonceSize + TagSize + ciphertext.Length]; + Buffer.BlockCopy(salt, 0, payload, 0, SaltSize); + Buffer.BlockCopy(nonce, 0, payload, SaltSize, NonceSize); + Buffer.BlockCopy(tag, 0, payload, SaltSize + NonceSize, TagSize); + Buffer.BlockCopy(ciphertext, 0, payload, SaltSize + NonceSize + TagSize, ciphertext.Length); + return payload; + } +} diff --git a/Journal.Core/Services/Vault/VaultStorageService.cs b/Journal.Core/Services/Vault/VaultStorageService.cs new file mode 100644 index 0000000..a676a8a --- /dev/null +++ b/Journal.Core/Services/Vault/VaultStorageService.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using Journal.Core.Services.Database; + +namespace Journal.Core.Services.Vault; + +public class VaultStorageService(IVaultCryptoService crypto, IJournalDatabaseService database) : IVaultStorageService +{ + private readonly IVaultCryptoService _crypto = crypto; + private readonly IJournalDatabaseService _database = database; + private readonly object _vaultIoLock = new(); + + private const string DatabaseVaultPrefix = "_db_"; + private const string DatabaseVaultSuffix = ".vault"; + + public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory) + { + EnsureRequiredArguments(password, vaultDirectory, dataDirectory); + + lock (_vaultIoLock) + { + var dbDirectory = GetDatabaseDirectory(); + Directory.CreateDirectory(dbDirectory); + if (!Directory.Exists(vaultDirectory)) + return true; + + return RestoreDatabaseVaults(password, vaultDirectory, dbDirectory); + } + } + + public void RebuildAllVaults(string password, string vaultDirectory, string dataDirectory) + { + EnsureRequiredArguments(password, vaultDirectory, dataDirectory); + + lock (_vaultIoLock) + { + Directory.CreateDirectory(vaultDirectory); + var dbDirectory = GetDatabaseDirectory(); + if (!Directory.Exists(dbDirectory)) + return; + + SaveDatabaseVaults(password, vaultDirectory, dbDirectory); + } + } + + public void ClearDataDirectory(string dataDirectory) + { + if (string.IsNullOrWhiteSpace(dataDirectory)) + throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); + + lock (_vaultIoLock) + { + var normalizedDataDir = Path.GetFullPath(dataDirectory); + var dbDirectory = GetDatabaseDirectory(); + if (string.Equals(normalizedDataDir, dbDirectory, StringComparison.OrdinalIgnoreCase)) + return; + + DeleteDirectoryWithRetries(normalizedDataDir); + } + } + + private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory) + { + var dbFiles = Directory.GetFiles(dataDirectory, "*.db"); + foreach (var dbPath in dbFiles) + { + try + { + var dbFileName = Path.GetFileName(dbPath); + var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}"; + var vaultPath = Path.Combine(vaultDirectory, vaultFileName); + + var dbBytes = File.ReadAllBytes(dbPath); + var encrypted = _crypto.EncryptData(dbBytes, password); + File.WriteAllBytes(vaultPath, encrypted); + } + catch (Exception ex) + { + Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}"); + } + } + } + + private bool RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory) + { + var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}"); + if (dbVaultFiles.Length == 0) + return true; + + var anyRestored = false; + foreach (var vaultFile in dbVaultFiles) + { + try + { + var vaultFileName = Path.GetFileName(vaultFile); + var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length]; + if (string.IsNullOrWhiteSpace(dbFileName)) + continue; + + var encrypted = File.ReadAllBytes(vaultFile); + var dbBytes = _crypto.DecryptData(encrypted, password); + var targetPath = Path.Combine(dataDirectory, dbFileName); + File.WriteAllBytes(targetPath, dbBytes); + anyRestored = true; + } + catch (CryptographicException) + { + Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)"); + } + catch (Exception ex) + { + Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}"); + } + } + + return anyRestored; + } + + private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + if (string.IsNullOrWhiteSpace(vaultDirectory)) + throw new ArgumentException("Vault directory is required.", nameof(vaultDirectory)); + if (string.IsNullOrWhiteSpace(dataDirectory)) + throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); + } + + private string GetDatabaseDirectory() + { + var dbPath = _database.GetDatabasePath(); + var directory = Path.GetDirectoryName(dbPath); + return string.IsNullOrWhiteSpace(directory) + ? Path.GetFullPath(".") + : Path.GetFullPath(directory); + } + + private static void DeleteDirectoryWithRetries(string dataDirectory, int retries = 5, int delayMs = 200) + { + if (!Directory.Exists(dataDirectory)) + return; + + for (var attempt = 0; attempt < retries; attempt++) + { + try + { + Directory.Delete(dataDirectory, recursive: true); + return; + } + catch (IOException) when (attempt < retries - 1) + { + Thread.Sleep(delayMs); + } + catch (UnauthorizedAccessException) when (attempt < retries - 1) + { + Thread.Sleep(delayMs); + } + } + + Directory.Delete(dataDirectory, recursive: true); + } +} diff --git a/Journal.DevTool/DevTool.Engine.dll b/Journal.DevTool/DevTool.Engine.dll new file mode 100644 index 0000000..99c4dbf Binary files /dev/null and b/Journal.DevTool/DevTool.Engine.dll differ diff --git a/Journal.DevTool/DevTool.Engine.pdb b/Journal.DevTool/DevTool.Engine.pdb new file mode 100644 index 0000000..ededda9 Binary files /dev/null and b/Journal.DevTool/DevTool.Engine.pdb differ diff --git a/Journal.DevTool/DevTool.Host.Bridge.dll b/Journal.DevTool/DevTool.Host.Bridge.dll new file mode 100644 index 0000000..5d60e0d Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Bridge.dll differ diff --git a/Journal.DevTool/DevTool.Host.Bridge.pdb b/Journal.DevTool/DevTool.Host.Bridge.pdb new file mode 100644 index 0000000..71b9127 Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Bridge.pdb differ diff --git a/Journal.DevTool/DevTool.Host.Tui.dll b/Journal.DevTool/DevTool.Host.Tui.dll new file mode 100644 index 0000000..52f5ce3 Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Tui.dll differ diff --git a/Journal.DevTool/DevTool.Host.Tui.pdb b/Journal.DevTool/DevTool.Host.Tui.pdb new file mode 100644 index 0000000..325d700 Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Tui.pdb differ diff --git a/Journal.DevTool/DevTool.Runtime.dll b/Journal.DevTool/DevTool.Runtime.dll new file mode 100644 index 0000000..61e3279 Binary files /dev/null and b/Journal.DevTool/DevTool.Runtime.dll differ diff --git a/Journal.DevTool/DevTool.Runtime.pdb b/Journal.DevTool/DevTool.Runtime.pdb new file mode 100644 index 0000000..4c8b134 Binary files /dev/null and b/Journal.DevTool/DevTool.Runtime.pdb differ diff --git a/Journal.DevTool/README.md b/Journal.DevTool/README.md new file mode 100644 index 0000000..43a289e --- /dev/null +++ b/Journal.DevTool/README.md @@ -0,0 +1,283 @@ +# SDT (Stan's Dev Tools) + +Cross-platform terminal orchestrator for project workflows, toolchain checks, and prerequisite gating. + +## Current State + +- Standalone `.NET` TUI app (`net10.0`) +- Domain-separated source projects under `src/`: + - `DevTool.Engine` (workflow/config/orchestration services) + - `DevTool.Runtime` (process/command execution primitives) + - `DevTool.Host.Tui` (terminal UI host surface) +- Workflow-first config model in `devtool.json` +- Strict-by-default legacy migration (`targets`-only configs fail unless compat mode is enabled) +- Python-first diagnostics/build script layer under `scripts/` +- Fail-fast execution with install prompt gating for missing prerequisites +- Debug profiles with attach metadata and diagnostics bundle generation +- Workspace-first project switching with support for external project paths +- Workspace-level defaults layering via `sdt-defaults.json` (ancestor defaults merged, project config wins) +- Project status tracking is maintained in `ROADMAP.md` +- Core run-event stream (`RunEvent`) shared by workflow + debug execution (TUI consumes it; GUI-ready) +- Run events are persisted to JSONL at `.sdt/events/` for external tooling/GUI consumers +- Run events now include versioned contract fields: `run_event_version`, `run_id`, `project_root`, `env_profile`, `timestamp_utc`, `event_type` +- TUI includes `SYSTEM -> View run events` to inspect persisted JSONL event logs +- `SYSTEM -> Run config doctor` can apply common autofixes (missing working dirs, legacy migration) +- `SYSTEM -> Keybinding help` provides normalized cross-platform shortcut guidance +- `SYSTEM -> Run history` supports rerun from prior execution context +- First-run projects are prompted to run a setup wizard (doctor + autofix + optional toolchain setup) +- Toolchain management now includes toolchain doctor + auto-fix flow with installer prompts and post-install verification +- Env profiles (`envProfiles`) support deterministic inheritance (`dev`/`ci`/`release`) and runtime profile selection from `SYSTEM -> Select env profile` +- Diagnostics bundles include managed secret redaction policy (env-key pattern redaction + output token redaction) +- Workspace quick actions/favorites can run workflows across projects (auto switch-and-run) +- Quick-action pinning is supported from workflow run results and events viewer +- Bootstrap detects additional project stacks (`go`, `maven`, `gradle`) and sets `project.type` (`dotnet`, `node`, `python`, `rust`, `go`, `java`, `tauri`, `polyglot`, `generic`) +- Headless execution mode is available for workflow/debug automation with JSON output +- Terminal capability fallback modes supported via `NO_COLOR`/`SDT_NO_COLOR` and `SDT_NO_UNICODE` + +## Run + +```powershell +dotnet run --project DevTool.csproj +``` + +Run from any subdirectory inside a project; SDT walks up to find `devtool.json`. + +If `devtool.json` is missing, SDT now offers to scan the repo and generate a default config. + +Explicit bootstrap command: + +```powershell +dotnet run --project DevTool.csproj -- init +``` + +Headless workflow/debug commands: + +```powershell +sdt run <workflowId> --json [--project-root <path>] [--env-profile <id>] [--non-interactive] +sdt debug <profileId> --json [--project-root <path>] [--env-profile <id>] [--non-interactive] +``` + +GUI bridge read/manage command: + +```powershell +sdt bridge --stdio [--project-root <path>] +``` + +Workspace inventory scan (GUI/TUI shared discovery contract): + +```powershell +sdt workspace scan --json [--project-root <path>] +``` + +`SDT_NONINTERACTIVE=1` globally enables non-interactive behavior for install prompts. + +Bootstrap detects common stacks (`dotnet`, `npm/node`, `python`, `cargo/tauri`, `go`, `maven/gradle`, `git`, `docker`) and generates: + +- default workflows +- toolchain/tooling defaults +- debug profiles + diagnostics defaults + +## Config Model + +SDT supports both: + +- `workflows` (preferred) +- `targets` (legacy; compat mode only) + +### Legacy Migration Mode (v1.2) + +- Default: strict mode +- Behavior: `targets`-only config fails early with migration instructions +- Preview file: SDT writes `devtool.generated.workflows.json` for migration help +- Temporary rollback: set `SDT_LEGACY_MODE=compat` + +Permanent fix (recommended): + +1. Open `devtool.generated.workflows.json` +2. Copy its `workflows` into `devtool.json` +3. Remove or empty legacy `targets` +4. Run `sdt.exe` again in strict mode + +### Workflow shape (preferred) + +```json +{ + "id": "build", + "label": "Build", + "description": "Build project", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "dotnet-build", + "label": "dotnet build", + "action": "dotnet-build", + "actionArgs": [], + "workingDir": ".", + "requires": [ + { "tool": "dotnet", "installPolicy": "Prompt" } + ] + } + ] +} +``` + +### Extra sections + +- `tooling.tools[].preferredInstallCommands`: preferred install commands per tool +- `tooling.tools[].executables`: explicit executable candidates for non-standard PATH setups +- `project.rootHints`: files/folders that identify project root +- `env`: session-level environment variable editor values +- `debug.profiles[]`: run/attach debug profiles +- `debug.diagnostics`: diagnostics bundle policy (`.sdt/debug` by default) + - secure default: allowlist-only environment capture + - set `includeAllEnv=true` to opt into full environment capture + +### Workspace Defaults Layering + +If SDT finds `sdt-defaults.json` in the project directory tree (current project root or an ancestor), it merges it into the effective config before runtime: + +- base layer: `sdt-defaults.json` +- override layer: project `devtool.json` (project values win) + +Merge behavior: + +- objects merge recursively +- arrays/scalars are replaced when project provides the property + +This is useful for shared defaults like toolchains, diagnostics policies, and baseline env definitions across multiple projects in one workspace. + +## Execution Behavior + +For each workflow step: + +1. Resolve dependencies (topological order) +2. Probe required tools +3. If missing, show install commands and prompt (`Prompt` policy) +4. On decline/install failure/step failure, stop immediately +5. Render step summary table with exit code + elapsed time +6. On workflow/debug failure, generate diagnostics bundle when enabled + +Installer command precedence: + +1. `tooling.tools[].preferredInstallCommands` +2. `scripts/diag.py install-plan` +3. built-in C# fallback templates (used automatically if script planning fails) + +When a tool probe fails, SDT now prints probe diagnostics (including command resolution source/path) in run output before prompting for installs. + +Headless exit code contract: + +- `0` success +- `10` missing prerequisite +- `11` install failed +- `12` command failed +- `13` validation/config error +- `14` user-declined / non-interactive prompt refusal + +## Scripts + +See [scripts/README.md](/e:/stansshit/csharp/DevTool-master/scripts/README.md). + +Primary Python entrypoints: + +- `scripts/diag.py` +- `scripts/build.py` +- `scripts/dotnet-min.py` +- `scripts/pip-min.py` +- `scripts/publish-*.py` + +## Workspace Support + +- Uses `sdt-workspace.json` when present +- If missing, can auto-discover nearby projects containing `devtool.json` +- Workspace screen can add external project roots (absolute paths supported) +- `projects[].disabled`, `projects[].tags`, and `projects[].toolFamilies` are supported +- Hybrid inventory model discovers marker-only candidates (`.slnx/.sln/.csproj`) without silently mutating workspace config +- TUI workspace screen supports: + - `Add candidate` + - `Add + initialize devtool.json` + - `Ignore for now` (session-only) +- Inventory snapshot is cached at `.sdt/workspace-inventory.json` for GUI-readiness + +## GUI Direction + +- Planned GUI stack for current phase: **Tauri-first** +- Avalonia is deferred for re-evaluation after first GUI milestone ships on top of the same headless contracts +- GUI workspace scaffold: [TauriShell README](/e:/stansshit/csharp/DevTool-master/src/DevTool.Host.Gui/TauriShell/README.md) +- Hybrid GUI bridge is active: + - execution: `sdt run/debug --json` + - read/manage: `sdt bridge --stdio` +- Bridge contract doc: [gui-bridge-contract.md](/e:/stansshit/csharp/DevTool-master/docs/gui-bridge-contract.md) +- Parity manifest: [gui-tui-parity.json](/e:/stansshit/csharp/DevTool-master/docs/gui-tui-parity.json) +- GUI will consume: + - `sdt workspace scan --json` inventory payload + - `run/debug --json` summaries + - persisted run events from `.sdt/events/*.jsonl` + +## Dev Shell Bootstrap + +Python-first cross-shell dev environment bootstrap: + +```powershell +# PowerShell +. ./scripts/dev-shell.ps1 + +# cmd +scripts\dev-shell.cmd +``` + +```bash +# bash/zsh +source ./scripts/dev-shell.sh +``` + +Underlying implementation is `scripts/dev_shell.py`: + +- `python scripts/dev_shell.py export --shell pwsh --json` +- `python scripts/dev_shell.py doctor` + +## Legacy PowerShell Compatibility + +Legacy `.ps1` scripts remain for migration compatibility only. New functionality is implemented in Python scripts first. `script-common.ps1` is legacy-only. + +Legacy runtime behavior in v1.2: + +- strict mode rejects `targets`-only configs by default +- compat mode (`SDT_LEGACY_MODE=compat`) temporarily allows legacy execution +- TUI `SYSTEM` includes `Migrate legacy targets -> workflows` to apply migration in place (with backup) +- Python reroute is authoritative for legacy `pwsh -File ...ps1` targets +- `.ps1` fallback is opt-in only: set `SDT_PWSH_LEGACY_FALLBACK=1` for temporary compatibility + +Deprecation target: + +- v1.x: compatibility only (no new behavior guarantees) +- v2.0: remove legacy `.ps1` scripts from default SDT workflows and docs + +## Testing + +Run unit/integration tests: + +```powershell +dotnet test tests/DevTool.Tests/DevTool.Tests.csproj +``` + +Run Python script smoke checks: + +```powershell +python -m py_compile scripts/*.py +``` + +Verify workflow route/path resolution: + +```powershell +python scripts/verify-workflow-routes.py --project-root . +python scripts/verify-workflow-routes.py --project-root . --workflow build --workflow tauri --execute --env-profile dev +``` + +## Reliability Matrix + +- CI matrix workflow: [reliability-matrix.yml](/e:/stansshit/csharp/DevTool-master/.github/workflows/reliability-matrix.yml) +- Runbook: [reliability-matrix-runbook.md](/e:/stansshit/csharp/DevTool-master/docs/reliability-matrix-runbook.md) +- Results log: [reliability-matrix-results.md](/e:/stansshit/csharp/DevTool-master/docs/reliability-matrix-results.md) +- Milestone status (Windows/Linux shipped, macOS delegated): [matrix-status.md](/e:/stansshit/csharp/DevTool-master/docs/matrix-status.md) diff --git a/Journal.DevTool/Spectre.Console.dll b/Journal.DevTool/Spectre.Console.dll new file mode 100644 index 0000000..85cd7b4 Binary files /dev/null and b/Journal.DevTool/Spectre.Console.dll differ diff --git a/Journal.DevTool/scripts/_pwsh-python-shim.ps1 b/Journal.DevTool/scripts/_pwsh-python-shim.ps1 new file mode 100644 index 0000000..4a16a55 --- /dev/null +++ b/Journal.DevTool/scripts/_pwsh-python-shim.ps1 @@ -0,0 +1,47 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-SdtIsWindows { + if (Get-Variable -Name IsWindows -Scope Global -ErrorAction SilentlyContinue) { + return [bool]$global:IsWindows + } + + return $env:OS -eq 'Windows_NT' +} + +function Resolve-SdtPython { + $candidates = @('python') + if (Test-SdtIsWindows) { $candidates += 'py' } else { $candidates += 'python3' } + foreach ($c in $candidates) { + try { + & $c --version *> $null + if ($LASTEXITCODE -eq 0) { return $c } + } catch {} + } + return 'python' +} + +function Resolve-SdtScriptPath { + param([Parameter(Mandatory=$true)][string]$ScriptName) + + $bundled = Join-Path $PSScriptRoot $ScriptName + if (Test-Path $bundled) { return $bundled } + + $project = Join-Path (Join-Path $PSScriptRoot '..') ('scripts\\' + $ScriptName) + if (Test-Path $project) { return (Resolve-Path $project).Path } + + throw "Python helper script not found: $ScriptName" +} + +function Invoke-SdtPythonScript { + param( + [Parameter(Mandatory=$true)][string]$ScriptName, + [string[]]$ForwardArgs = @() + ) + + $python = Resolve-SdtPython + $scriptPath = Resolve-SdtScriptPath -ScriptName $ScriptName + + & $python $scriptPath @ForwardArgs + exit $LASTEXITCODE +} diff --git a/Journal.DevTool/scripts/build.py b/Journal.DevTool/scripts/build.py new file mode 100644 index 0000000..39676ce --- /dev/null +++ b/Journal.DevTool/scripts/build.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +import os +import pathlib +import shutil +import subprocess +import sys +import time +from script_common import resolve_command + + +def run_step(command, args, cwd): + resolved = resolve_command(command) + if shutil.which(resolved) is None and not pathlib.Path(resolved).exists(): + return { + "command": resolved, + "args": args, + "cwd": cwd, + "exit_code": 127, + "elapsed_seconds": 0.0, + "status": "failed", + "failure_reason": f"command_not_found:{resolved}", + } + + started = time.time() + proc = subprocess.run([resolved, *args], cwd=cwd, check=False) + elapsed = round(time.time() - started, 3) + return { + "command": resolved, + "args": args, + "cwd": cwd, + "exit_code": proc.returncode, + "elapsed_seconds": elapsed, + "status": "ok" if proc.returncode == 0 else "failed", + "failure_reason": None if proc.returncode == 0 else f"non_zero_exit:{proc.returncode}", + } + + +def resolve_python_executable(): + candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"] + for c in candidates: + if shutil.which(c): + return c + return "python" + + +def parse_common(parser): + parser.add_argument("--project-root", required=True) + parser.add_argument("--working-dir", default=".") + parser.add_argument("--json", action="store_true") + + +def resolve_cwd(project_root, working_dir): + return os.path.abspath(os.path.join(project_root, working_dir)) + + +EXCLUDED_SCAN_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", ".sdt", "dist", "build"} + + +def discover_dotnet_target(project_root: str, cwd: str): + # Prefer local solution first (.slnx, then .sln), then csproj, then bounded scan from project root. + local_slnx = sorted(pathlib.Path(cwd).glob("*.slnx")) + if len(local_slnx) == 1: + return str(local_slnx[0]) + + local_sln = sorted(pathlib.Path(cwd).glob("*.sln")) + if len(local_sln) == 1: + return str(local_sln[0]) + + local_csproj = sorted(pathlib.Path(cwd).glob("*.csproj")) + if len(local_csproj) == 1: + return str(local_csproj[0]) + + slnx_hits = bounded_find_files(project_root, ".slnx", max_depth=4) + if len(slnx_hits) == 1: + return slnx_hits[0] + + sln_hits = bounded_find_files(project_root, ".sln", max_depth=4) + if len(sln_hits) == 1: + return sln_hits[0] + + csproj_hits = bounded_find_files(project_root, ".csproj", max_depth=4) + if len(csproj_hits) == 1: + return csproj_hits[0] + + return None + + +def bounded_find_files(root: str, extension: str, max_depth: int): + root_path = pathlib.Path(root).resolve() + results = [] + for current_root, dirs, files in os.walk(root_path): + rel = pathlib.Path(current_root).resolve().relative_to(root_path) + depth = len(rel.parts) + dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRS] + if depth > max_depth: + dirs[:] = [] + continue + + for name in files: + if name.lower().endswith(extension.lower()): + results.append(str(pathlib.Path(current_root) / name)) + return sorted(results) + + +def run_dotnet_action(project_root, working_dir, verb): + cwd = resolve_cwd(project_root, working_dir) + target = discover_dotnet_target(project_root, cwd) + if not target: + return 0, { + "command": "dotnet", + "args": [verb], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_dotnet_target", + "message": "No .slnx/.sln/.csproj found for this step. Skipping dotnet action.", + } + + args = [verb, target] + step = run_step("dotnet", args, cwd) + step["resolved_target"] = target + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def _deps_hash(app_root): + h = hashlib.sha256() + for name in ("package.json", "package-lock.json"): + p = pathlib.Path(app_root) / name + if p.exists(): + h.update(p.read_bytes()) + return h.hexdigest() + + +def ensure_npm_dependencies(app_root): + package_json = pathlib.Path(app_root) / "package.json" + if not package_json.exists(): + return {"installed": False, "reason": "not_applicable"} + + node_modules = pathlib.Path(app_root) / "node_modules" + deps_hash_file = node_modules / ".sdt-deps.sha256" + expected = _deps_hash(app_root) + + should_install = not node_modules.exists() + if not should_install: + if not deps_hash_file.exists(): + should_install = True + else: + current = deps_hash_file.read_text(encoding="utf-8").strip() + should_install = current != expected + + if not should_install: + return {"installed": False, "reason": "deps_unchanged"} + + lock_exists = (pathlib.Path(app_root) / "package-lock.json").exists() + install_args = ["ci", "--no-audit", "--fund=false"] if lock_exists else ["install", "--no-audit", "--fund=false"] + install_step = run_step("npm", install_args, app_root) + if install_step["exit_code"] != 0: + if lock_exists and install_args[0] == "ci": + fallback = run_step("npm", ["install", "--no-audit", "--fund=false"], app_root) + if fallback["exit_code"] != 0: + fallback["failure_reason"] = "deps_install_failed_after_ci_fallback" + return {"installed": True, "reason": "install_failed", "step": fallback} + install_step = fallback + else: + return {"installed": True, "reason": "install_failed", "step": install_step} + + node_modules.mkdir(parents=True, exist_ok=True) + deps_hash_file.write_text(expected, encoding="utf-8") + return {"installed": True, "reason": "installed", "step": install_step} + + +def read_package_json(cwd: str): + package_json = pathlib.Path(cwd) / "package.json" + if not package_json.exists(): + return None + try: + return json.loads(package_json.read_text(encoding="utf-8")) + except Exception: + return None + + +def has_npm_script(cwd: str, script_name: str) -> bool: + data = read_package_json(cwd) + if not isinstance(data, dict): + return False + scripts = data.get("scripts") + if not isinstance(scripts, dict): + return False + return script_name in scripts and isinstance(scripts.get(script_name), str) + + +def action_dotnet_build(args): + return run_dotnet_action(args.project_root, args.working_dir, "build") + + +def action_dotnet_restore(args): + return run_dotnet_action(args.project_root, args.working_dir, "restore") + + +def action_dotnet_test(args): + return run_dotnet_action(args.project_root, args.working_dir, "test") + + +def action_dotnet_publish(args): + return run_dotnet_action(args.project_root, args.working_dir, "publish") + + +def action_npm_install(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + if not (pathlib.Path(cwd) / "package.json").exists(): + return 0, { + "command": "npm", + "args": ["install"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_package_json", + } + step = run_step("npm", ["install"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_npm_ci(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + if not (pathlib.Path(cwd) / "package.json").exists(): + return 0, { + "command": "npm", + "args": ["ci"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_package_json", + } + step = run_step("npm", ["ci"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_npm_build(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + if not (pathlib.Path(cwd) / "package.json").exists(): + return 0, { + "command": "npm", + "args": ["run", "build"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_package_json", + } + if not has_npm_script(cwd, "build"): + return 0, { + "command": "npm", + "args": ["run", "build"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_missing_build_script", + } + deps = ensure_npm_dependencies(cwd) + if deps.get("reason") == "not_applicable": + return 0, { + "command": "npm", + "args": ["run", "build"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_package_json", + } + if deps.get("reason") == "install_failed": + step = deps["step"] + step["failure_reason"] = "deps_install_failed" + return step["exit_code"], step + step = run_step("npm", ["run", "build"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_npm_test(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + if not (pathlib.Path(cwd) / "package.json").exists(): + return 0, { + "command": "npm", + "args": ["test"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_package_json", + } + if not has_npm_script(cwd, "test"): + return 0, { + "command": "npm", + "args": ["test"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_missing_test_script", + } + deps = ensure_npm_dependencies(cwd) + if deps.get("reason") == "not_applicable": + return 0, { + "command": "npm", + "args": ["test"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_package_json", + } + if deps.get("reason") == "install_failed": + step = deps["step"] + step["failure_reason"] = "deps_install_failed" + return step["exit_code"], step + step = run_step("npm", ["test"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_npm_audit(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + if not (pathlib.Path(cwd) / "package.json").exists(): + return 0, { + "command": "npm", + "args": ["audit"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_package_json", + } + step = run_step("npm", ["audit"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_python_venv_create(args): + cwd = resolve_cwd(args.project_root, ".") + venv_dir = args.venv_dir or ".venv" + step = run_step(resolve_python_executable(), ["-m", "venv", venv_dir], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_python_pip_install(args): + cwd = resolve_cwd(args.project_root, ".") + req = args.requirements + step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_python_pip_sync(args): + cwd = resolve_cwd(args.project_root, ".") + req = args.requirements + step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_python_pytest(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_cargo_build(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + if not (pathlib.Path(cwd) / "Cargo.toml").exists(): + return 0, { + "command": "cargo", + "args": ["build"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_cargo_toml", + } + step = run_step("cargo", ["build"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_cargo_test(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + if not (pathlib.Path(cwd) / "Cargo.toml").exists(): + return 0, { + "command": "cargo", + "args": ["test"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_cargo_toml", + } + step = run_step("cargo", ["test"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_tauri_build(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + tauri_conf = pathlib.Path(cwd) / "src-tauri" / "tauri.conf.json" + if not tauri_conf.exists(): + tauri_conf = pathlib.Path(cwd) / "tauri.conf.json" + if not tauri_conf.exists() or not (pathlib.Path(cwd) / "package.json").exists(): + return 0, { + "command": "npm", + "args": ["run", "tauri", "build"], + "cwd": cwd, + "exit_code": 0, + "elapsed_seconds": 0.0, + "status": "skipped", + "failure_reason": None, + "skip_reason": "not_applicable_no_tauri_project", + } + + deps = ensure_npm_dependencies(cwd) + if deps.get("reason") == "install_failed": + step = deps["step"] + step["failure_reason"] = "deps_install_failed" + return step["exit_code"], step + + tauri_args = ["run", "tauri", "build"] + if args.no_bundle: + tauri_args.extend(["--", "--no-bundle"]) + step = run_step("npm", tauri_args, cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_git_status(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step("git", ["status"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_git_fetch(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step("git", ["fetch"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_git_pull(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step("git", ["pull"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_git_clean(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step("git", ["clean", "-fd"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_docker_build(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step("docker", ["build", "."], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_docker_compose_up(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step("docker", ["compose", "up", "-d"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def action_docker_compose_down(args): + cwd = resolve_cwd(args.project_root, args.working_dir) + step = run_step("docker", ["compose", "down"], cwd) + return 0 if step["exit_code"] == 0 else step["exit_code"], step + + +def main(): + parser = argparse.ArgumentParser(description="SDT normalized build actions") + sub = parser.add_subparsers(dest="action", required=True) + + p0 = sub.add_parser("dotnet-restore") + parse_common(p0) + + p1 = sub.add_parser("dotnet-build") + parse_common(p1) + + p1b = sub.add_parser("dotnet-test") + parse_common(p1b) + + p1c = sub.add_parser("dotnet-publish") + parse_common(p1c) + + p2 = sub.add_parser("npm-install") + parse_common(p2) + + p2b = sub.add_parser("npm-ci") + parse_common(p2b) + + p3 = sub.add_parser("npm-build") + parse_common(p3) + + p3b = sub.add_parser("npm-test") + parse_common(p3b) + + p3c = sub.add_parser("npm-audit") + parse_common(p3c) + + p4 = sub.add_parser("python-venv-create") + parse_common(p4) + p4.add_argument("--venv-dir", default=".venv") + + p5 = sub.add_parser("python-pip-install") + parse_common(p5) + p5.add_argument("--requirements", required=True) + + p5b = sub.add_parser("python-pip-sync") + parse_common(p5b) + p5b.add_argument("--requirements", required=True) + + p5c = sub.add_parser("python-pytest") + parse_common(p5c) + + p6 = sub.add_parser("cargo-build") + parse_common(p6) + + p6b = sub.add_parser("cargo-test") + parse_common(p6b) + + p7 = sub.add_parser("tauri-build") + parse_common(p7) + p7.add_argument("--no-bundle", action="store_true") + + p8 = sub.add_parser("git-status") + parse_common(p8) + + p9 = sub.add_parser("git-fetch") + parse_common(p9) + + p10 = sub.add_parser("git-pull") + parse_common(p10) + + p11 = sub.add_parser("git-clean") + parse_common(p11) + + p12 = sub.add_parser("docker-build") + parse_common(p12) + + p13 = sub.add_parser("docker-compose-up") + parse_common(p13) + + p14 = sub.add_parser("docker-compose-down") + parse_common(p14) + + args = parser.parse_args() + + handlers = { + "dotnet-restore": action_dotnet_restore, + "dotnet-build": action_dotnet_build, + "dotnet-test": action_dotnet_test, + "dotnet-publish": action_dotnet_publish, + "npm-install": action_npm_install, + "npm-ci": action_npm_ci, + "npm-build": action_npm_build, + "npm-test": action_npm_test, + "npm-audit": action_npm_audit, + "python-venv-create": action_python_venv_create, + "python-pip-install": action_python_pip_install, + "python-pip-sync": action_python_pip_sync, + "python-pytest": action_python_pytest, + "cargo-build": action_cargo_build, + "cargo-test": action_cargo_test, + "tauri-build": action_tauri_build, + "git-status": action_git_status, + "git-fetch": action_git_fetch, + "git-pull": action_git_pull, + "git-clean": action_git_clean, + "docker-build": action_docker_build, + "docker-compose-up": action_docker_compose_up, + "docker-compose-down": action_docker_compose_down, + } + + code, summary = handlers[args.action](args) + if args.json: + print(json.dumps(summary)) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Journal.DevTool/scripts/dev-shell.cmd b/Journal.DevTool/scripts/dev-shell.cmd new file mode 100644 index 0000000..b1614b7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.cmd @@ -0,0 +1,17 @@ +@echo off +set "SCRIPT_DIR=%~dp0" + +where py >nul 2>nul +if %ERRORLEVEL%==0 ( + set "PYEXE=py" +) else ( + where python >nul 2>nul + if not %ERRORLEVEL%==0 ( + echo python not found. + exit /b 1 + ) + set "PYEXE=python" +) + +for /f "usebackq delims=" %%L in (`"%PYEXE%" "%SCRIPT_DIR%dev_shell.py" export --shell cmd`) do %%L +echo Development shell initialized from Python bootstrap script. diff --git a/Journal.DevTool/scripts/dev-shell.ps1 b/Journal.DevTool/scripts/dev-shell.ps1 new file mode 100644 index 0000000..7f4a3f7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.ps1 @@ -0,0 +1,21 @@ +# Run this in PowerShell before development commands: +# . ./scripts/dev-shell.ps1 + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1') + +$scriptPath = Resolve-SdtScriptPath -ScriptName 'dev_shell.py' +$python = Resolve-SdtPython + +$lines = & $python $scriptPath export --shell pwsh +if ($LASTEXITCODE -ne 0) { + throw "Failed to initialize development shell via dev_shell.py" +} + +foreach ($line in $lines) { + Invoke-Expression $line +} + +Write-Host "Development shell initialized from Python bootstrap script." diff --git a/Journal.DevTool/scripts/dev-shell.sh b/Journal.DevTool/scripts/dev-shell.sh new file mode 100644 index 0000000..83468f7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +if command -v python3 >/dev/null 2>&1; then + PYTHON_EXE="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_EXE="python" +else + echo "python3/python not found." >&2 + exit 1 +fi + +eval "$("$PYTHON_EXE" "$SCRIPT_DIR/dev_shell.py" export --shell bash)" +echo "Development shell initialized from Python bootstrap script." diff --git a/Journal.DevTool/scripts/dev_shell.py b/Journal.DevTool/scripts/dev_shell.py new file mode 100644 index 0000000..1a5d8ea --- /dev/null +++ b/Journal.DevTool/scripts/dev_shell.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import sys + +from script_common import PROXY_VARS, clean_proxy_env, dotnet_env, ensure_dirs, pip_env, resolve_repo_root + + +def huggingface_env(repo_root: pathlib.Path) -> dict[str, str]: + env = {} + hf_home = repo_root / ".cache" / "huggingface" + hf_hub_cache = hf_home / "hub" + ensure_dirs([hf_hub_cache]) + env["HF_HOME"] = str(hf_home) + env["HUGGINGFACE_HUB_CACHE"] = str(hf_hub_cache) + env["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" + return env + + +def resolved_env(repo_root: pathlib.Path) -> dict[str, str]: + env = {} + dotnet = dotnet_env(repo_root) + pip = pip_env(repo_root) + hf = huggingface_env(repo_root) + + dotnet_keys = [ + "DOTNET_CLI_HOME", + "NUGET_PACKAGES", + "NUGET_HTTP_CACHE_PATH", + "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", + "DOTNET_ADD_GLOBAL_TOOLS_TO_PATH", + "DOTNET_GENERATE_ASPNET_CERTIFICATE", + "DOTNET_CLI_TELEMETRY_OPTOUT", + "NUGET_CERT_REVOCATION_MODE", + ] + pip_keys = [ + "PIP_CACHE_DIR", + "PIP_DISABLE_PIP_VERSION_CHECK", + "PIP_DEFAULT_TIMEOUT", + "PIP_RETRIES", + "TEMP", + "TMP", + ] + for key in dotnet_keys: + env[key] = dotnet[key] + for key in pip_keys: + env[key] = pip[key] + env.update(hf) + clean_proxy_env(env) + return env + + +def export_lines(shell: str, env_map: dict[str, str]) -> list[str]: + def sh_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + if shell == "pwsh": + lines = [f"Remove-Item Env:{k} -ErrorAction SilentlyContinue" for k in PROXY_VARS] + lines.extend(f"$env:{k} = \"{v.replace('\"', '`\"')}\"" for k, v in env_map.items()) + return lines + if shell in ("bash", "zsh"): + lines = [f"unset {k}" for k in PROXY_VARS] + lines.extend(f"export {k}={sh_quote(v)}" for k, v in env_map.items()) + return lines + if shell == "cmd": + lines = [f"set {k}=" for k in PROXY_VARS] + lines.extend(f"set {k}={v}" for k, v in env_map.items()) + return lines + raise ValueError(shell) + + +def cmd_export(args): + try: + repo_root = resolve_repo_root(args.project_root) + except Exception as ex: + print(f"Failed to resolve project root: {ex}", file=sys.stderr) + return 2 + + env_map = resolved_env(repo_root) + payload = { + "projectRoot": str(repo_root), + "env": env_map, + "createdDirs": [ + str(repo_root / ".dotnet_home"), + str(repo_root / ".nuget" / "packages"), + str(repo_root / ".nuget" / "http-cache"), + str(repo_root / ".pip" / "cache"), + str(repo_root / ".tmp" / "pip-temp"), + str(repo_root / ".cache" / "huggingface" / "hub"), + ], + "warnings": [], + } + + try: + lines = export_lines(args.shell, env_map) + except ValueError: + print(f"Unsupported shell target: {args.shell}", file=sys.stderr) + return 3 + + if args.json: + print(json.dumps(payload)) + else: + for line in lines: + print(line) + return 0 + + +def cmd_doctor(args): + try: + repo_root = resolve_repo_root(args.project_root) + except Exception as ex: + print(f"Failed to resolve project root: {ex}", file=sys.stderr) + return 2 + + env_map = resolved_env(repo_root) + checks = { + "repo_root": str(repo_root), + "dotnet_home_exists": (repo_root / ".dotnet_home").exists(), + "nuget_cache_exists": (repo_root / ".nuget" / "packages").exists(), + "pip_cache_exists": (repo_root / ".pip" / "cache").exists(), + "hf_cache_exists": (repo_root / ".cache" / "huggingface" / "hub").exists(), + "env_count": len(env_map), + } + print(json.dumps(checks)) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="SDT cross-platform shell bootstrap helper") + sub = parser.add_subparsers(dest="command", required=True) + + p_export = sub.add_parser("export", help="Print env exports for a shell") + p_export.add_argument("--shell", required=True) + p_export.add_argument("--project-root") + p_export.add_argument("--json", action="store_true") + + p_doctor = sub.add_parser("doctor", help="Validate env bootstrap paths") + p_doctor.add_argument("--project-root") + + args = parser.parse_args() + if args.command == "export": + return cmd_export(args) + return cmd_doctor(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Journal.DevTool/scripts/diag.py b/Journal.DevTool/scripts/diag.py new file mode 100644 index 0000000..20bf41b --- /dev/null +++ b/Journal.DevTool/scripts/diag.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +from script_common import resolve_command + + +def run_capture(cmd): + try: + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + text = out if out else err + return proc.returncode == 0, text + except Exception as ex: + return False, str(ex) + + +def probe_tool(tool): + mapping = { + "dotnet": ["dotnet", "--version"], + "node": ["node", "--version"], + "npm": ["npm", "--version"], + "python": ["python", "--version"], + "cargo": ["cargo", "--version"], + "tauri": ["tauri", "--version"], + "git": ["git", "--version"], + "docker": ["docker", "--version"], + } + cmd = mapping.get(tool, [tool, "--version"]) + resolved = resolve_command(cmd[0]) + if shutil.which(resolved) is None and not os.path.exists(resolved): + return {"tool": tool, "available": False, "version": None, "details": f"{cmd[0]} not found in PATH"} + cmd = [resolved, *cmd[1:]] + ok, text = run_capture(cmd) + return {"tool": tool, "available": ok, "version": text if ok else None, "details": None if ok else text} + + +def install_plan(tool): + is_windows = platform.system().lower().startswith("win") + if is_windows: + plans = { + "dotnet": [("winget", ["install", "Microsoft.DotNet.SDK.10"])], + "node": [("winget", ["install", "OpenJS.NodeJS.LTS"])], + "npm": [("winget", ["install", "OpenJS.NodeJS.LTS"])], + "python": [("winget", ["install", "Python.Python.3.12"])], + "cargo": [("winget", ["install", "Rustlang.Rustup"])], + "tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])], + "git": [("winget", ["install", "Git.Git"])], + "docker": [("winget", ["install", "Docker.DockerDesktop"])], + } + else: + plans = { + "dotnet": [("sh", ["-c", "echo install dotnet sdk with your package manager"])], + "node": [("sh", ["-c", "echo install nodejs with your package manager"])], + "npm": [("sh", ["-c", "echo install npm with your package manager"])], + "python": [("sh", ["-c", "echo install python3 with your package manager"])], + "cargo": [("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"])], + "tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])], + "git": [("sh", ["-c", "echo install git with your package manager"])], + "docker": [("sh", ["-c", "echo install docker with your package manager"])], + } + + cmds = plans.get(tool, []) + return { + "tool": tool, + "supported": len(cmds) > 0, + "summary": f"Install plan for {tool} on {platform.system()}", + "commands": [{"command": c, "args": a} for c, a in cmds], + } + + +def run_install(tool): + plan = install_plan(tool) + if not plan["supported"]: + return 2 + for cmd in plan["commands"]: + proc = subprocess.run([cmd["command"], *cmd["args"]], check=False) + if proc.returncode != 0: + return proc.returncode + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="SDT diagnostics and install planner") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_probe = sub.add_parser("probe") + p_probe.add_argument("--tool", required=True) + p_probe.add_argument("--json", action="store_true") + + p_plan = sub.add_parser("install-plan") + p_plan.add_argument("--tool", required=True) + p_plan.add_argument("--json", action="store_true") + + p_run = sub.add_parser("install-run") + p_run.add_argument("--tool", required=True) + + args = parser.parse_args() + + if args.cmd == "probe": + result = probe_tool(args.tool.lower()) + if args.json: + print(json.dumps(result)) + else: + print(result) + return 0 if result["available"] else 1 + + if args.cmd == "install-plan": + result = install_plan(args.tool.lower()) + if args.json: + print(json.dumps(result)) + else: + print(result) + return 0 if result["supported"] else 2 + + if args.cmd == "install-run": + return run_install(args.tool.lower()) + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Journal.DevTool/scripts/dotnet-min.py b/Journal.DevTool/scripts/dotnet-min.py new file mode 100644 index 0000000..c8aa0f4 --- /dev/null +++ b/Journal.DevTool/scripts/dotnet-min.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import argparse +import sys + +from script_common import dotnet_env, resolve_repo_root, run + + +DOTNET_SAFE_CMDS = {"restore", "build", "run", "test", "publish", "pack"} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform minimal dotnet wrapper") + parser.add_argument("dotnet_args", nargs=argparse.REMAINDER) + parser.add_argument("--repo-root", default=None) + args = parser.parse_args() + + if not args.dotnet_args: + print("Usage: python scripts/dotnet-min.py <dotnet args>", file=sys.stderr) + return 2 + + repo_root = resolve_repo_root(args.repo_root) + dotnet_args = list(args.dotnet_args) + cmd = dotnet_args[0].lower() + + if cmd in DOTNET_SAFE_CMDS: + dotnet_args.extend(["-p:RestoreIgnoreFailedSources=true", "-p:NuGetAudit=false"]) + if cmd == "restore": + dotnet_args.append("--ignore-failed-sources") + + return run("dotnet", dotnet_args, repo_root, env=dotnet_env(repo_root)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/migration-gate.py b/Journal.DevTool/scripts/migration-gate.py new file mode 100644 index 0000000..398cf09 --- /dev/null +++ b/Journal.DevTool/scripts/migration-gate.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import sys +from pathlib import Path + +from script_common import resolve_repo_root + + +def run_step(repo_root: Path, title: str, command: list[str]) -> int: + print(f"\n== {title} ==") + print("$", " ".join(command)) + proc = subprocess.run(command, cwd=str(repo_root), check=False) + return proc.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform migration quality gate") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--skip-tests", action="store_true") + parser.add_argument("--test-project", default=None, help="Optional test csproj path") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + + code = run_step(repo_root, "Build", [sys.executable, "scripts/dotnet-min.py", "build"]) + if code != 0: + return code + + if not args.skip_tests: + if args.test_project: + test_cmd = [sys.executable, "scripts/dotnet-min.py", "test", args.test_project] + else: + test_cmd = [sys.executable, "scripts/dotnet-min.py", "test"] + code = run_step(repo_root, "Tests", test_cmd) + if code != 0: + return code + + print("\nMigration gate passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/npm-clean.py b/Journal.DevTool/scripts/npm-clean.py new file mode 100644 index 0000000..48d8881 --- /dev/null +++ b/Journal.DevTool/scripts/npm-clean.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import argparse +import shutil + + +from script_common import resolve_repo_root + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform node_modules cleanup") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--working-dir", default=".") + parser.add_argument("--also-cache", action="store_true") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + work_dir = (repo_root / args.working_dir).resolve() + node_modules = work_dir / "node_modules" + if node_modules.exists(): + shutil.rmtree(node_modules) + print(f"Removed: {node_modules}") + else: + print(f"Not found: {node_modules}") + + if args.also_cache: + npm_cache = repo_root / ".npm" / "cache" + if npm_cache.exists(): + shutil.rmtree(npm_cache) + print(f"Removed: {npm_cache}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/nuget-export-cache.py b/Journal.DevTool/scripts/nuget-export-cache.py new file mode 100644 index 0000000..17720f7 --- /dev/null +++ b/Journal.DevTool/scripts/nuget-export-cache.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import argparse +import shutil +import tempfile +from pathlib import Path + +from script_common import resolve_repo_root + + +def main() -> int: + parser = argparse.ArgumentParser(description="Export local NuGet cache to zip") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--output-zip", default="nuget-cache-export.zip") + parser.add_argument("--include-dotnet-home", action="store_true") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_zip = (repo_root / args.output_zip).resolve() + + nuget_dir = repo_root / ".nuget" + dotnet_home = repo_root / ".dotnet_home" + if not nuget_dir.exists(): + print(f"NuGet cache not found: {nuget_dir}") + return 2 + + with tempfile.TemporaryDirectory() as td: + stage = Path(td) / "cache-export" + stage.mkdir(parents=True, exist_ok=True) + shutil.copytree(nuget_dir, stage / ".nuget") + if args.include_dotnet_home and dotnet_home.exists(): + shutil.copytree(dotnet_home, stage / ".dotnet_home") + manifest = stage / "nuget-cache-manifest.txt" + manifest.write_text("exported_by=nuget-export-cache.py\n", encoding="utf-8") + archive_base = str(output_zip.with_suffix("")) + shutil.make_archive(archive_base, "zip", root_dir=str(stage)) + + print(f"Exported cache: {output_zip}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/nuget-import-cache.py b/Journal.DevTool/scripts/nuget-import-cache.py new file mode 100644 index 0000000..608ed60 --- /dev/null +++ b/Journal.DevTool/scripts/nuget-import-cache.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +import argparse +import shutil + + +from script_common import resolve_repo_root + + +def main() -> int: + parser = argparse.ArgumentParser(description="Import NuGet cache from zip") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--input-zip", default="nuget-cache-export.zip") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + input_zip = (repo_root / args.input_zip).resolve() + if not input_zip.exists(): + print(f"Input zip not found: {input_zip}") + return 2 + + shutil.unpack_archive(str(input_zip), extract_dir=str(repo_root)) + print(f"Imported cache from: {input_zip}") + print("Run `python scripts/dotnet-min.py restore` to validate restore in this repo.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/pip-min.py b/Journal.DevTool/scripts/pip-min.py new file mode 100644 index 0000000..fd03343 --- /dev/null +++ b/Journal.DevTool/scripts/pip-min.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import argparse +import os +import sys + +from script_common import pip_env, resolve_repo_root, run + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform minimal pip wrapper") + parser.add_argument("pip_args", nargs=argparse.REMAINDER) + parser.add_argument("--repo-root", default=None) + args = parser.parse_args() + + if not args.pip_args: + print("Usage: python scripts/pip-min.py <pip args>", file=sys.stderr) + return 2 + + repo_root = resolve_repo_root(args.repo_root) + pip_args = list(args.pip_args) + + # Preserve legacy behavior: for bare install, default target to repo-local deps. + if pip_args and pip_args[0].lower() == "install": + has_target = any(a in ("--target", "--prefix") for a in pip_args) + if not has_target: + pip_args = [a for a in pip_args if a != "--user"] + target = repo_root / ".pydeps" / f"py{sys.version_info.major}{sys.version_info.minor}" + os.makedirs(target, exist_ok=True) + pip_args.extend(["--target", str(target)]) + + return run(sys.executable, ["-m", "pip", *pip_args], repo_root, env=pip_env(repo_root)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/pip_safe.py b/Journal.DevTool/scripts/pip_safe.py new file mode 100644 index 0000000..f520fc2 --- /dev/null +++ b/Journal.DevTool/scripts/pip_safe.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import os +import tempfile + + + +def _mkdtemp_compat( + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, +) -> str: + # Python 3.14 on some Windows hosts creates mkdtemp dirs that are + # immediately non-writable by the same process when mode=0o700 is used. + # pip relies heavily on tempfile; force 0o777 for compatibility. + if dir is None: + dir = tempfile.gettempdir() + if prefix is None: + prefix = tempfile.template + if suffix is None: + suffix = "" + + names = tempfile._get_candidate_names() + for _ in range(tempfile.TMP_MAX): + name = next(names) + path = os.path.join(dir, f"{prefix}{name}{suffix}") + try: + os.mkdir(path, 0o777) + return path + except FileExistsError: + continue + + raise FileExistsError("No usable temporary directory name found.") + + +def main(argv: list[str]) -> int: + tempfile.mkdtemp = _mkdtemp_compat # type: ignore[assignment] + + from pip._internal.cli.main import main as pip_main + + return int(pip_main(argv)) + + +if __name__ == "__main__": + raise SystemExit(main(__import__("sys").argv[1:])) + diff --git a/Journal.DevTool/scripts/publish-app.py b/Journal.DevTool/scripts/publish-app.py new file mode 100644 index 0000000..9f82a8f --- /dev/null +++ b/Journal.DevTool/scripts/publish-app.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +import argparse + + +from script_common import find_node_app_root, resolve_repo_root, run, sha256_files + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform web/tauri publish helper") + parser.add_argument("--target", choices=["web", "tauri"], default="web") + parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") + parser.add_argument("--tauri-bundles", choices=["none", "nsis", "msi"], default="none") + parser.add_argument("--install-deps", action="store_true") + parser.add_argument("--skip-install", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--app-root", default=None, help="Relative or absolute app root with package.json") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + app_root = find_node_app_root(repo_root, args.app_root) + if app_root is None: + print("Unable to locate app root (no unique package.json found).") + return 2 + + package_json = app_root / "package.json" + lock_file = app_root / "package-lock.json" + node_modules = app_root / "node_modules" + deps_hash_file = node_modules / ".sdt-deps.sha256" + expected_hash = sha256_files([package_json, lock_file]) + + should_install = args.install_deps or not node_modules.exists() + if not should_install and not args.skip_install: + if not deps_hash_file.exists(): + should_install = True + else: + current = deps_hash_file.read_text(encoding="utf-8").strip() + should_install = current != expected_hash + if args.skip_install: + should_install = False + + print(f"App root: {app_root}") + print(f"Target: {args.target} ({args.configuration})") + + if should_install: + install_args = ["ci", "--no-audit", "--fund=false"] if lock_file.exists() else ["install", "--no-audit", "--fund=false"] + print("$ npm " + " ".join(install_args)) + if not args.dry_run: + code = run("npm", install_args, app_root) + if code != 0: + if lock_file.exists() and install_args[0] == "ci": + print("npm ci failed (likely lockfile out of sync). Falling back to npm install...") + fallback_args = ["install", "--no-audit", "--fund=false"] + print("$ npm " + " ".join(fallback_args)) + code = run("npm", fallback_args, app_root) + if code != 0: + return code + else: + return code + node_modules.mkdir(parents=True, exist_ok=True) + deps_hash_file.write_text(expected_hash, encoding="utf-8") + else: + print("Skipping dependency install.") + + if args.target == "web": + cmd = ["run", "build"] + print("$ npm " + " ".join(cmd)) + if not args.dry_run: + return run("npm", cmd, app_root) + return 0 + + tauri_cmd = ["run", "tauri", "build"] + tauri_tail: list[str] = [] + if args.tauri_bundles == "none": + tauri_tail.extend(["--no-bundle"]) + else: + tauri_tail.extend(["--bundles", args.tauri_bundles]) + if args.configuration == "Debug": + tauri_tail.append("--debug") + if tauri_tail: + tauri_cmd.extend(["--", *tauri_tail]) + + print("$ npm " + " ".join(tauri_cmd)) + if not args.dry_run: + return run("npm", tauri_cmd, app_root) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/publish-output.py b/Journal.DevTool/scripts/publish-output.py new file mode 100644 index 0000000..82c4aaa --- /dev/null +++ b/Journal.DevTool/scripts/publish-output.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +from script_common import find_csproj_by_keyword, find_node_app_root, resolve_repo_root + + +def run_step(label: str, cmd: list[str], cwd: Path, dry_run: bool) -> int: + print(f"\n> {label}") + print("$", " ".join(cmd)) + if dry_run: + return 0 + proc = subprocess.run(cmd, cwd=str(cwd), check=False) + return proc.returncode + + +def has_package_script(app_root: Path, script_name: str) -> bool: + package_json = app_root / "package.json" + if not package_json.exists(): + return False + try: + data = json.loads(package_json.read_text(encoding="utf-8")) + except Exception: + return False + scripts = data.get("scripts") + if not isinstance(scripts, dict): + return False + value = scripts.get(script_name) + return isinstance(value, str) and value.strip() != "" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Publish bundled outputs using Python script entrypoints") + parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") + parser.add_argument("--runtime", default="win-x64") + parser.add_argument("--skip-sidecar", action="store_true") + parser.add_argument("--skip-web", action="store_true") + parser.add_argument("--skip-webgateway", action="store_true") + parser.add_argument("--skip-tauri", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--sidecar-project", default=None) + parser.add_argument("--gateway-project", default=None) + parser.add_argument("--app-root", default=None) + parser.add_argument("--output-dir", default="output") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_root = (repo_root / args.output_dir).resolve() + output_root.mkdir(parents=True, exist_ok=True) + + sidecar_project = (repo_root / args.sidecar_project).resolve() if args.sidecar_project else find_csproj_by_keyword(repo_root, ["sidecar"]) + gateway_project = (repo_root / args.gateway_project).resolve() if args.gateway_project else find_csproj_by_keyword(repo_root, ["webgateway", "gateway"]) + app_root = (repo_root / args.app_root).resolve() if args.app_root else find_node_app_root(repo_root, None) + tauri_conf = None + if app_root is not None: + candidate_a = app_root / "src-tauri" / "tauri.conf.json" + candidate_b = app_root / "tauri.conf.json" + if candidate_a.exists(): + tauri_conf = candidate_a + elif candidate_b.exists(): + tauri_conf = candidate_b + + py = sys.executable + if not args.skip_sidecar: + if sidecar_project is None: + print("Skipping sidecar: no sidecar csproj detected.") + else: + cmd = [py, "scripts/publish-sidecar.py", "--configuration", args.configuration, "--runtime", args.runtime] + cmd.extend(["--project", str(sidecar_project)]) + code = run_step("Publish sidecar", cmd, repo_root, args.dry_run) + if code != 0: + return code + + if not args.skip_web: + if app_root is None: + print("Skipping web: no app root with package.json detected.") + elif not has_package_script(app_root, "build"): + print("Skipping web: package.json has no 'build' script.") + else: + cmd = [py, "scripts/publish-app.py", "--target", "web", "--configuration", args.configuration, "--app-root", str(app_root)] + code = run_step("Build web", cmd, repo_root, args.dry_run) + if code != 0: + return code + + if not args.skip_webgateway: + if gateway_project is None: + print("Skipping web gateway: no gateway csproj detected.") + else: + cmd = [py, "scripts/publish-webgateway.py", "--configuration", args.configuration, "--runtime", args.runtime, "--project", str(gateway_project)] + code = run_step("Publish web gateway", cmd, repo_root, args.dry_run) + if code != 0: + return code + + if not args.skip_tauri: + if app_root is None or tauri_conf is None: + print("Skipping tauri: tauri app not detected.") + elif not has_package_script(app_root, "tauri"): + print("Skipping tauri: package.json has no 'tauri' script.") + else: + cmd = [py, "scripts/publish-app.py", "--target", "tauri", "--configuration", args.configuration, "--tauri-bundles", "none", "--app-root", str(app_root)] + code = run_step("Build tauri", cmd, repo_root, args.dry_run) + if code != 0: + return code + + target_dir = app_root / "src-tauri" / "target" / ("debug" if args.configuration == "Debug" else "release") + exes = sorted(target_dir.glob("*.exe"), key=lambda p: p.stat().st_mtime, reverse=True) + if exes: + staged = output_root / exes[0].name + if args.dry_run: + print(f"Would copy: {exes[0]} -> {staged}") + else: + shutil.copy2(exes[0], staged) + print(f"Staged desktop executable: {staged}") + + print("\nPublish output workflow complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/publish-sidecar.py b/Journal.DevTool/scripts/publish-sidecar.py new file mode 100644 index 0000000..964f750 --- /dev/null +++ b/Journal.DevTool/scripts/publish-sidecar.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import argparse + + +from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform .NET sidecar publish helper") + parser.add_argument("--configuration", default="Release") + parser.add_argument("--runtime", default="win-x64") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--project", default=None, help="Relative/absolute path to sidecar csproj") + parser.add_argument("--output-dir", default="output") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_dir = (repo_root / args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + if args.project: + csproj = (repo_root / args.project).resolve() + else: + csproj = find_csproj_by_keyword(repo_root, ["sidecar"]) + if csproj is None or not csproj.exists(): + print("Could not locate sidecar project. Pass --project <path/to/project.csproj>.") + return 2 + + publish_args = [ + "publish", + str(csproj), + "-c", + args.configuration, + "-r", + args.runtime, + "--self-contained", + "-p:PublishSingleFile=true", + "-p:IncludeNativeLibrariesForSelfExtract=true", + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + "-o", + str(output_dir), + ] + code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root)) + if code != 0: + return code + + binary_name = csproj.stem + (".exe" if args.runtime.startswith("win-") else "") + binary_path = output_dir / binary_name + if binary_path.exists(): + print(f"Published executable: {binary_path}") + else: + print(f"Publish completed. Output directory: {output_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/publish-webgateway.py b/Journal.DevTool/scripts/publish-webgateway.py new file mode 100644 index 0000000..6a2c9c0 --- /dev/null +++ b/Journal.DevTool/scripts/publish-webgateway.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +import argparse +import shutil + +from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform ASP.NET gateway publish helper") + parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") + parser.add_argument("--runtime", default="win-x64") + parser.add_argument("--self-contained", action="store_true") + parser.add_argument("--skip-web-assets", action="store_true") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--project", default=None, help="Relative/absolute path to gateway csproj") + parser.add_argument("--web-build-dir", default=None, help="Relative path to web build assets root") + parser.add_argument("--output-dir", default="output/webgateway") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_dir = (repo_root / args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + if args.project: + csproj = (repo_root / args.project).resolve() + else: + csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"]) + if csproj is None or not csproj.exists(): + print("Could not locate web gateway project. Pass --project <path/to/project.csproj>.") + return 2 + + publish_args = [ + "publish", + str(csproj), + "-c", + args.configuration, + "-r", + args.runtime, + "--self-contained", + "true" if args.self_contained else "false", + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + "-o", + str(output_dir), + ] + code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root)) + if code != 0: + return code + + if not args.skip_web_assets: + if args.web_build_dir: + web_build_dir = (repo_root / args.web_build_dir).resolve() + else: + web_build_dir = next((p.parent for p in repo_root.rglob("package.json") if (p.parent / "build").exists()), None) + if web_build_dir is not None: + web_build_dir = web_build_dir / "build" + + if web_build_dir is None or not web_build_dir.exists(): + print("Web assets not found. Skip with --skip-web-assets or pass --web-build-dir.") + else: + web_out = output_dir / "wwwroot" + web_out.mkdir(parents=True, exist_ok=True) + for item in web_build_dir.iterdir(): + dst = web_out / item.name + if item.is_dir(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(item, dst) + else: + shutil.copy2(item, dst) + print(f"Copied web assets: {web_out}") + + print(f"Publish completed: {output_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/run-webgateway.py b/Journal.DevTool/scripts/run-webgateway.py new file mode 100644 index 0000000..3f35dbc --- /dev/null +++ b/Journal.DevTool/scripts/run-webgateway.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import argparse +import os +from pathlib import Path + +from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run gateway in dev or output mode") + parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") + parser.add_argument("--urls", default="http://0.0.0.0:5180") + parser.add_argument("--project-root", default=None, help="Runtime project root exposed via SDT_PROJECT_ROOT") + parser.add_argument("--mode", choices=["Dev", "Output"], default="Dev") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--project", default=None, help="Gateway csproj path") + parser.add_argument("--output-exe", default=None, help="Published gateway executable path") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + effective_project_root = Path(args.project_root).resolve() if args.project_root else repo_root + if not effective_project_root.exists(): + print(f"Project root does not exist: {effective_project_root}") + return 2 + + env = dotnet_env(repo_root) + env["SDT_PROJECT_ROOT"] = str(effective_project_root) + + if args.mode == "Output": + exe_path = Path(args.output_exe).resolve() if args.output_exe else (repo_root / "output" / "webgateway" / ("webgateway.exe" if os.name == "nt" else "webgateway")) + if not exe_path.exists(): + print(f"Output executable not found: {exe_path}") + return 2 + return run(str(exe_path), ["--urls", args.urls], repo_root, env=env) + + if args.project: + csproj = (repo_root / args.project).resolve() + else: + csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"]) + if csproj is None or not csproj.exists(): + print("Could not locate gateway project. Pass --project <path/to/project.csproj>.") + return 2 + + run_args = [ + "run", + "--project", + str(csproj), + "-c", + args.configuration, + "--no-launch-profile", + "--urls", + args.urls, + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + ] + return run("dotnet", run_args, repo_root, env=env) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/script_common.py b/Journal.DevTool/scripts/script_common.py new file mode 100644 index 0000000..03faa87 --- /dev/null +++ b/Journal.DevTool/scripts/script_common.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +import hashlib +import json +import os +import pathlib +import shutil +import subprocess +import sys +from typing import Dict, Iterable, List, Sequence + + +PROXY_VARS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "GIT_HTTP_PROXY", + "GIT_HTTPS_PROXY", + "PIP_NO_INDEX", +] + + +def resolve_repo_root(start: str | None = None) -> pathlib.Path: + base = pathlib.Path(start or os.getcwd()).resolve() + + # Preferred marker for SDT-managed projects. + for cur in [base, *base.parents]: + cfg = cur / "devtool.json" + if cfg.exists(): + hints = load_project_root_hints(cur) + if not hints: + return cur + if any(_hint_matches(cur, hint) for hint in hints): + return cur + + # Fall back to git root when available. + try: + proc = subprocess.run( + ["git", "-C", str(base), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode == 0: + git_root = proc.stdout.strip() + if git_root: + return pathlib.Path(git_root).resolve() + except Exception: + pass + + return base + + +def load_project_root_hints(repo_root: pathlib.Path) -> list[str]: + cfg = repo_root / "devtool.json" + if not cfg.exists(): + return [] + try: + data = json.loads(cfg.read_text(encoding="utf-8")) + hints = data.get("project", {}).get("rootHints", []) + return [str(x) for x in hints if isinstance(x, str) and x.strip()] + except Exception: + return [] + + +def ensure_dirs(paths: List[pathlib.Path]) -> None: + for p in paths: + p.mkdir(parents=True, exist_ok=True) + + +def clean_proxy_env(env: Dict[str, str]) -> None: + for k in PROXY_VARS: + env.pop(k, None) + + +def dotnet_env(repo_root: pathlib.Path) -> Dict[str, str]: + env = dict(os.environ) + clean_proxy_env(env) + dotnet_cli_home = repo_root / ".dotnet_home" + nuget_packages = repo_root / ".nuget" / "packages" + nuget_http_cache = repo_root / ".nuget" / "http-cache" + ensure_dirs([dotnet_cli_home, nuget_packages, nuget_http_cache]) + env["DOTNET_CLI_HOME"] = str(dotnet_cli_home) + env["NUGET_PACKAGES"] = str(nuget_packages) + env["NUGET_HTTP_CACHE_PATH"] = str(nuget_http_cache) + env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" + env["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "0" + env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0" + env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + env["NUGET_CERT_REVOCATION_MODE"] = "offline" + return env + + +def pip_env(repo_root: pathlib.Path) -> Dict[str, str]: + env = dict(os.environ) + clean_proxy_env(env) + pip_cache = repo_root / ".pip" / "cache" + pip_tmp = repo_root / ".tmp" / "pip-temp" + ensure_dirs([pip_cache, pip_tmp]) + env["PIP_CACHE_DIR"] = str(pip_cache) + env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + env["PIP_DEFAULT_TIMEOUT"] = "30" + env["PIP_RETRIES"] = "2" + env["TEMP"] = str(pip_tmp) + env["TMP"] = str(pip_tmp) + return env + + +def run(command: str, args: List[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> int: + resolved = resolve_command(command) + try: + proc = subprocess.run([resolved, *args], cwd=str(cwd), env=env, check=False) + return proc.returncode + except FileNotFoundError: + print(f"Command not found: {resolved}", file=sys.stderr) + return 127 + + +def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> tuple[int, str, str]: + resolved = resolve_command(command) + try: + proc = subprocess.run( + [resolved, *args], + cwd=str(cwd), + env=env, + capture_output=True, + text=True, + check=False, + ) + return proc.returncode, proc.stdout, proc.stderr + except FileNotFoundError: + return 127, "", f"Command not found: {resolved}" + + +def resolve_command(command: str) -> str: + if not command: + return command + + if os.name != "nt": + return command + + if any(sep in command for sep in ("\\", "/")): + return command + + if pathlib.Path(command).suffix: + found = shutil.which(command) + return found or command + + candidates = [] + lowered = command.lower() + if lowered in ("npm", "npx", "pnpm", "yarn", "tauri"): + candidates.extend([f"{command}.cmd", f"{command}.exe", f"{command}.bat", command]) + else: + candidates.append(command) + + for c in candidates: + found = _which_windows(c) + if found: + name = pathlib.Path(found).name.lower() + if name in ("npm", "npx", "pnpm", "yarn", "tauri"): + shim = pathlib.Path(found).with_name(name + ".cmd") + if shim.exists(): + return str(shim) + return found + + if lowered in ("npm", "npx", "pnpm", "yarn"): + node = _which_windows("node.exe") or _which_windows("node") + if node: + node_dir = pathlib.Path(node).parent + shim = node_dir / f"{lowered}.cmd" + if shim.exists(): + return str(shim) + + return candidates[-1] + + +def _hint_matches(root: pathlib.Path, hint: str) -> bool: + h = hint.strip() + if not h: + return False + + has_glob = any(ch in h for ch in ("*", "?", "[")) + if has_glob: + # Match both anywhere in root and directly at root-level for common hints like "*.sln". + if any(root.glob(h)): + return True + return any(root.rglob(h)) + + marker = root / h + if marker.exists(): + return True + + # If hint is just a filename marker, look bounded in tree. + if not any(sep in h for sep in ("\\", "/")): + return any(p.name == h for p in root.rglob(h)) + + return False + + +def _expand_windows_path_segment(segment: str) -> str: + expanded = segment + # Expand %VAR% tokens repeatedly for nested references. + for _ in range(4): + next_value = os.path.expandvars(expanded) + if next_value == expanded: + break + expanded = next_value + return expanded + + +def _which_windows(command: str) -> str | None: + found = shutil.which(command) + if found: + return found + + if os.name != "nt": + return None + + path_value = os.environ.get("PATH", "") + pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD") + exts = [e.lower() for e in pathext.split(";") if e] + + has_ext = pathlib.Path(command).suffix != "" + names = [command] if has_ext else [command, *(command + e.lower() for e in exts)] + + for raw_segment in path_value.split(os.pathsep): + segment = _expand_windows_path_segment(raw_segment.strip()) + if not segment: + continue + base = pathlib.Path(segment) + for name in names: + candidate = base / name + if candidate.exists(): + return str(candidate) + + return None + + +def sha256_files(paths: Iterable[pathlib.Path]) -> str: + h = hashlib.sha256() + for p in paths: + if not p.exists(): + continue + h.update(p.read_bytes()) + return h.hexdigest() + + +def first_existing(paths: Iterable[pathlib.Path]) -> pathlib.Path | None: + for p in paths: + if p.exists(): + return p + return None + + +def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> pathlib.Path | None: + if hints: + for hint in hints: + candidate = (repo_root / hint).resolve() + if candidate.exists() and candidate.suffix.lower() == ".csproj": + return candidate + + csprojs = sorted(repo_root.rglob("*.csproj")) + if not csprojs: + return None + if len(csprojs) == 1: + return csprojs[0] + return None + + +def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> pathlib.Path | None: + kws = [k.lower() for k in keywords] + matches: list[pathlib.Path] = [] + for p in repo_root.rglob("*.csproj"): + text = str(p).lower() + if any(k in text for k in kws): + matches.append(p) + if len(matches) == 1: + return matches[0] + return None + + +def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> pathlib.Path | None: + def _read_package_json(package_json: pathlib.Path) -> dict | None: + if not package_json.exists(): + return None + try: + data = json.loads(package_json.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + def _has_scripts(package_json: pathlib.Path, names: Sequence[str]) -> bool: + data = _read_package_json(package_json) + if not data: + return False + scripts = data.get("scripts") + if not isinstance(scripts, dict): + return False + for name in names: + value = scripts.get(name) + if isinstance(value, str) and value.strip(): + return True + return False + + def _is_tauri_root(candidate_dir: pathlib.Path) -> bool: + return (candidate_dir / "src-tauri" / "tauri.conf.json").exists() or (candidate_dir / "tauri.conf.json").exists() + + def _iter_package_jsons() -> list[pathlib.Path]: + excluded = {"node_modules", "dist", "build", ".git", ".sdt", ".venv", "venv", "bin", "obj"} + found: list[pathlib.Path] = [] + for current_root, dirs, files in os.walk(repo_root): + dirs[:] = [d for d in dirs if d not in excluded] + if "package.json" in files: + found.append(pathlib.Path(current_root) / "package.json") + found.sort(key=lambda p: len(p.parts)) + return found + + if preferred: + p = (repo_root / preferred).resolve() + package_json = p / "package.json" + if package_json.exists(): + # Keep explicit preferred root only when it appears runnable for node workflows. + if _is_tauri_root(p) or _has_scripts(package_json, ("build", "dev", "start", "test", "tauri")): + return p + + package_files = _iter_package_jsons() + if not package_files: + return None + + # Strong preference: a tauri app root with tauri config and package.json. + tauri_candidates = [p.parent for p in package_files if _is_tauri_root(p.parent)] + if len(tauri_candidates) == 1: + return tauri_candidates[0] + if len(tauri_candidates) > 1: + tauri_candidates.sort(key=lambda p: len(p.parts)) + return tauri_candidates[0] + + runnable_candidates = [ + p.parent for p in package_files if _has_scripts(p, ("build", "dev", "start", "test", "tauri")) + ] + if len(runnable_candidates) == 1: + return runnable_candidates[0] + if len(runnable_candidates) > 1: + runnable_candidates.sort(key=lambda p: len(p.parts)) + return runnable_candidates[0] + + # As a last fallback, return unique package root only. + if len(package_files) == 1: + return package_files[0].parent + return None + + +def newest_file(search_root: pathlib.Path, pattern: str) -> pathlib.Path | None: + if not search_root.exists(): + return None + files = [p for p in search_root.rglob(pattern) if p.is_file() and "\\obj\\" not in str(p).replace("/", "\\")] + if not files: + return None + files.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return files[0] diff --git a/Journal.DevTool/scripts/sync-output.py b/Journal.DevTool/scripts/sync-output.py new file mode 100644 index 0000000..8a28a2b --- /dev/null +++ b/Journal.DevTool/scripts/sync-output.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import argparse +import os +import shutil +from pathlib import Path + +from script_common import newest_file, resolve_repo_root + + +def copy_tree_contents(src: Path, dst: Path) -> None: + dst.mkdir(parents=True, exist_ok=True) + for item in src.iterdir(): + target = dst / item.name + if item.is_dir(): + if target.exists(): + shutil.rmtree(target) + shutil.copytree(item, target) + else: + shutil.copy2(item, target) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Sync newest built assets into output folder") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--output-dir", default="output") + parser.add_argument("--web-build-dir", default=None, help="Path to web build output") + parser.add_argument("--sidecar-bin-dir", default=None, help="Path to sidecar bin root") + parser.add_argument("--gateway-bin-dir", default=None, help="Path to gateway bin root") + parser.add_argument("--tauri-target-dir", default=None, help="Path to tauri target root") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_dir = (repo_root / args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + web_build = (repo_root / args.web_build_dir).resolve() if args.web_build_dir else None + if web_build is None: + web_build = next((p for p in repo_root.rglob("build") if (p.parent / "package.json").exists()), None) + if web_build is not None and web_build.exists(): + web_out = output_dir / "webgateway" / "wwwroot" + copy_tree_contents(web_build, web_out) + print(f"Synced web assets -> {web_out}") + + sidecar_bin = (repo_root / args.sidecar_bin_dir).resolve() if args.sidecar_bin_dir else None + if sidecar_bin is None: + sidecar_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "sidecar" in str(p).lower()), None) + sidecar_bin = sidecar_proj / "bin" if sidecar_proj else None + if sidecar_bin is not None: + sidecar_pattern = "*.exe" if os.name == "nt" else "*" + sidecar_exe = newest_file(sidecar_bin, sidecar_pattern) + if sidecar_exe is not None: + copy_tree_contents(sidecar_exe.parent, output_dir) + print(f"Synced sidecar -> {output_dir}") + + gateway_bin = (repo_root / args.gateway_bin_dir).resolve() if args.gateway_bin_dir else None + if gateway_bin is None: + gateway_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "gateway" in str(p).lower()), None) + gateway_bin = gateway_proj / "bin" if gateway_proj else None + if gateway_bin is not None: + gateway_pattern = "*.exe" if os.name == "nt" else "*" + gw_exe = newest_file(gateway_bin, gateway_pattern) + if gw_exe is not None: + gw_out = output_dir / "webgateway" + copy_tree_contents(gw_exe.parent, gw_out) + print(f"Synced gateway -> {gw_out}") + + tauri_target = (repo_root / args.tauri_target_dir).resolve() if args.tauri_target_dir else None + if tauri_target is None: + tauri_target = next((p for p in repo_root.rglob("src-tauri") if (p / "target").exists()), None) + tauri_target = tauri_target / "target" if tauri_target else None + if tauri_target is not None: + app_exe = newest_file(tauri_target, "*.exe") + if app_exe is not None: + shutil.copy2(app_exe, output_dir / app_exe.name) + print(f"Synced desktop app ({app_exe.name}) -> {output_dir}") + + print("Sync complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/verify-workflow-routes.py b/Journal.DevTool/scripts/verify-workflow-routes.py new file mode 100644 index 0000000..c03cbc5 --- /dev/null +++ b/Journal.DevTool/scripts/verify-workflow-routes.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import shutil +import subprocess +import sys +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from script_common import resolve_command, resolve_repo_root + + +def load_config(project_root: pathlib.Path) -> dict: + config_path = project_root / "devtool.json" + if not config_path.exists(): + raise FileNotFoundError(f"devtool.json not found at: {config_path}") + return json.loads(config_path.read_text(encoding="utf-8")) + + +def iter_workflows(config: dict, selected: Optional[set[str]]) -> List[dict]: + workflows = config.get("workflows", []) + if not isinstance(workflows, list): + return [] + normalized: List[dict] = [w for w in workflows if isinstance(w, dict) and isinstance(w.get("id"), str)] + if selected: + normalized = [w for w in normalized if w["id"] in selected] + return normalized + + +def is_command_available(command: str) -> bool: + resolved = resolve_command(command) + if pathlib.Path(resolved).is_file(): + return True + return shutil.which(resolved) is not None + + +def resolve_script_arg(project_root: pathlib.Path, working_dir: pathlib.Path, arg: str) -> pathlib.Path: + p = pathlib.Path(arg) + if p.is_absolute(): + return p + a = working_dir / p + if a.exists(): + return a + b = project_root / p + return b + + +def static_check_workflow(project_root: pathlib.Path, workflow: dict) -> dict: + result = { + "workflowId": workflow.get("id"), + "ok": True, + "issues": [], + "steps": [], + } + + for step in workflow.get("steps", []): + if not isinstance(step, dict): + continue + step_id = step.get("id", "<unknown>") + step_result = {"stepId": step_id, "ok": True, "issues": []} + + working_dir_rel = step.get("workingDir") or "." + working_dir = (project_root / working_dir_rel).resolve() + if not working_dir.exists(): + step_result["ok"] = False + step_result["issues"].append(f"workingDir_not_found:{working_dir}") + + command = step.get("command") + args = step.get("args") or [] + action = step.get("action") + + if isinstance(command, str) and command.strip(): + if not is_command_available(command): + step_result["ok"] = False + step_result["issues"].append(f"command_not_found:{command}") + + if command.lower() in ("python", "py", "python3", "python.exe", "py.exe"): + if args and isinstance(args[0], str) and args[0].endswith(".py"): + script_path = resolve_script_arg(project_root, working_dir, args[0]) + if not script_path.exists(): + step_result["ok"] = False + step_result["issues"].append(f"python_script_not_found:{script_path}") + + if isinstance(action, str) and action.strip(): + # Action-based steps still require workingDir existence for reliable execution. + if not working_dir.exists(): + step_result["ok"] = False + step_result["issues"].append("action_working_dir_not_found") + + if not step_result["ok"]: + result["ok"] = False + result["issues"].extend(step_result["issues"]) + + result["steps"].append(step_result) + + return result + + +def sdt_attempts(repo_root: pathlib.Path) -> List[List[str]]: + attempts: List[List[str]] = [] + attempts.append(["sdt"]) + if sys.platform.startswith("win"): + attempts.append(["sdt.exe"]) + + local_exe = repo_root / ("sdt.exe" if sys.platform.startswith("win") else "sdt") + if local_exe.exists(): + attempts.append([str(local_exe)]) + + devtool_csproj = repo_root / "DevTool.csproj" + if devtool_csproj.exists(): + attempts.append(["dotnet", "run", "--project", str(devtool_csproj), "--"]) + + # Preserve order but dedupe exact attempts. + seen = set() + unique: List[List[str]] = [] + for a in attempts: + key = tuple(a) + if key in seen: + continue + seen.add(key) + unique.append(a) + return unique + + +def try_run_sdt( + repo_root: pathlib.Path, + command_args: Sequence[str], + timeout_seconds: int, +) -> Tuple[Optional[subprocess.CompletedProcess], Optional[str]]: + errors: List[str] = [] + for base in sdt_attempts(repo_root): + cmd = [*base, *command_args] + try: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + text=True, + capture_output=True, + timeout=timeout_seconds, + check=False, + ) + return proc, " ".join(cmd) + except FileNotFoundError: + errors.append(f"not_found:{' '.join(cmd)}") + except subprocess.TimeoutExpired: + errors.append(f"timeout:{' '.join(cmd)}") + return None, "; ".join(errors) if errors else "no_sdt_attempts" + + +def parse_headless_summary(stdout: str) -> Optional[Dict[str, Any]]: + lines = [line.strip() for line in stdout.splitlines() if line.strip()] + for line in reversed(lines): + if not line.startswith("{"): + continue + try: + payload = json.loads(line) + except Exception: + continue + if isinstance(payload, dict) and "runId" in payload and "success" in payload: + return payload + return None + + +def execute_check_workflow( + repo_root: pathlib.Path, + project_root: pathlib.Path, + workflow_id: str, + env_profile: Optional[str], + timeout_seconds: int, +) -> dict: + args = [ + "run", + workflow_id, + "--json", + "--project-root", + str(project_root), + "--non-interactive", + ] + if env_profile: + args.extend(["--env-profile", env_profile]) + + proc, attempted = try_run_sdt(repo_root, args, timeout_seconds) + if proc is None: + return { + "workflowId": workflow_id, + "ok": False, + "attempted": attempted, + "exitCode": None, + "stopReason": "sdt_not_runnable", + "message": attempted, + } + + summary = parse_headless_summary(proc.stdout) + if summary is None: + return { + "workflowId": workflow_id, + "ok": False, + "attempted": attempted, + "exitCode": proc.returncode, + "stopReason": "missing_summary", + "message": (proc.stderr or proc.stdout).strip(), + } + + return { + "workflowId": workflow_id, + "ok": bool(summary.get("success", False)), + "attempted": attempted, + "exitCode": summary.get("exitCode"), + "stopReason": summary.get("stopReason"), + "message": summary.get("message"), + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify SDT workflow routes (static path checks + optional headless execution)." + ) + parser.add_argument("--repo-root", default=None, help="Repo root containing DevTool.csproj") + parser.add_argument("--project-root", default=".", help="Project root containing devtool.json") + parser.add_argument("--workflow", action="append", default=[], help="Workflow id to verify (repeatable)") + parser.add_argument("--execute", action="store_true", help="Also run each workflow via `sdt run ... --json`") + parser.add_argument("--env-profile", default=None) + parser.add_argument("--timeout-seconds", type=int, default=600) + parser.add_argument("--output-json", default=None, help="Write full report JSON to file") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + project_root = (repo_root / args.project_root).resolve() if not pathlib.Path(args.project_root).is_absolute() else pathlib.Path(args.project_root).resolve() + selected = set(args.workflow) if args.workflow else None + + config = load_config(project_root) + workflows = iter_workflows(config, selected) + if not workflows: + print("No workflows selected/found.") + return 2 + + static_results = [static_check_workflow(project_root, w) for w in workflows] + execute_results: List[dict] = [] + if args.execute: + for w in workflows: + wid = w["id"] + execute_results.append( + execute_check_workflow( + repo_root=repo_root, + project_root=project_root, + workflow_id=wid, + env_profile=args.env_profile, + timeout_seconds=args.timeout_seconds, + ) + ) + + static_failures = [r for r in static_results if not r["ok"]] + exec_failures = [r for r in execute_results if not r["ok"]] + + report = { + "repoRoot": str(repo_root), + "projectRoot": str(project_root), + "totalWorkflows": len(workflows), + "static": { + "checked": len(static_results), + "failed": len(static_failures), + "results": static_results, + }, + "execute": { + "enabled": args.execute, + "checked": len(execute_results), + "failed": len(exec_failures), + "results": execute_results, + }, + } + + if args.output_json: + out_path = pathlib.Path(args.output_json) + if not out_path.is_absolute(): + out_path = repo_root / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + print(f"Report written: {out_path}") + + print(f"Static checks: {len(static_results)} workflow(s), failures={len(static_failures)}") + if args.execute: + print(f"Execution checks: {len(execute_results)} workflow(s), failures={len(exec_failures)}") + + if static_failures: + print("\nStatic failures:") + for f in static_failures: + print(f"- {f['workflowId']}: {', '.join(f['issues'])}") + + if exec_failures: + print("\nExecution failures:") + for f in exec_failures: + print(f"- {f['workflowId']}: stopReason={f.get('stopReason')} message={f.get('message')}") + + return 1 if static_failures or exec_failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/sdt.deps.json b/Journal.DevTool/sdt.deps.json new file mode 100644 index 0000000..e07b2c6 --- /dev/null +++ b/Journal.DevTool/sdt.deps.json @@ -0,0 +1,108 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": { + "sdt/1.0.0": { + "dependencies": { + "DevTool.Engine": "1.0.0", + "DevTool.Host.Bridge": "1.0.0", + "DevTool.Host.Tui": "1.0.0", + "DevTool.Runtime": "1.0.0", + "Spectre.Console": "0.49.1" + }, + "runtime": { + "sdt.dll": {} + } + }, + "Spectre.Console/0.49.1": { + "runtime": { + "lib/net8.0/Spectre.Console.dll": { + "assemblyVersion": "0.0.0.0", + "fileVersion": "0.49.1.0" + } + } + }, + "DevTool.Engine/1.0.0": { + "dependencies": { + "DevTool.Runtime": "1.0.0" + }, + "runtime": { + "DevTool.Engine.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "DevTool.Host.Bridge/1.0.0": { + "dependencies": { + "DevTool.Engine": "1.0.0" + }, + "runtime": { + "DevTool.Host.Bridge.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "DevTool.Host.Tui/1.0.0": { + "dependencies": { + "DevTool.Engine": "1.0.0", + "DevTool.Runtime": "1.0.0", + "Spectre.Console": "0.49.1" + }, + "runtime": { + "DevTool.Host.Tui.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "DevTool.Runtime/1.0.0": { + "runtime": { + "DevTool.Runtime.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + } + } + }, + "libraries": { + "sdt/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Spectre.Console/0.49.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-USV+pdu49OJ3nCjxNuw1K9Zw/c1HCBbwbjXZp0EOn6wM99tFdAtN34KEBZUMyRuJuXlUMDqhd8Yq9obW2MslYA==", + "path": "spectre.console/0.49.1", + "hashPath": "spectre.console.0.49.1.nupkg.sha512" + }, + "DevTool.Engine/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "DevTool.Host.Bridge/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "DevTool.Host.Tui/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "DevTool.Runtime/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/Journal.DevTool/sdt.dll b/Journal.DevTool/sdt.dll new file mode 100644 index 0000000..faa199f Binary files /dev/null and b/Journal.DevTool/sdt.dll differ diff --git a/Journal.DevTool/sdt.exe b/Journal.DevTool/sdt.exe new file mode 100644 index 0000000..a8a4842 Binary files /dev/null and b/Journal.DevTool/sdt.exe differ diff --git a/Journal.DevTool/sdt.pdb b/Journal.DevTool/sdt.pdb new file mode 100644 index 0000000..dca518d Binary files /dev/null and b/Journal.DevTool/sdt.pdb differ diff --git a/Journal.DevTool/sdt.runtimeconfig.json b/Journal.DevTool/sdt.runtimeconfig.json new file mode 100644 index 0000000..f730443 --- /dev/null +++ b/Journal.DevTool/sdt.runtimeconfig.json @@ -0,0 +1,13 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false + } + } +} \ No newline at end of file diff --git a/Journal.Sidecar/App.cs b/Journal.Sidecar/App.cs new file mode 100644 index 0000000..95f3f3e --- /dev/null +++ b/Journal.Sidecar/App.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Journal.AI; +using Journal.Core; +using Journal.Core.Services.Speech; +using Journal.Core.Services.Sidecar; +using Journal.Sidecar; + +Console.OutputEncoding = System.Text.Encoding.UTF8; +Console.InputEncoding = System.Text.Encoding.UTF8; + +var services = new ServiceCollection(); +services.AddFragmentServices(); +services.AddLlamaSharpServices(); +services.AddSingleton<IS2TService, LocalWhisperS2TService>(); +services.AddSingleton<Entry>(); +var provider = services.BuildServiceProvider(); + +var entry = provider.GetRequiredService<Entry>(); +var cli = provider.GetRequiredService<SidecarCli>(); +var exitCode = await cli.RunAsync(args, entry); +Environment.ExitCode = exitCode; diff --git a/Journal.Sidecar/Journal.Sidecar.csproj b/Journal.Sidecar/Journal.Sidecar.csproj new file mode 100644 index 0000000..6035b91 --- /dev/null +++ b/Journal.Sidecar/Journal.Sidecar.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> + <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\Journal.AI\Journal.AI.csproj" /> + <ProjectReference Include="..\Journal.Core\Journal.Core.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> + <PackageReference Include="NAudio" /> + <PackageReference Include="Whisper.net" /> + <PackageReference Include="Whisper.net.Runtime" /> + </ItemGroup> + +</Project> diff --git a/Journal.Sidecar/LocalWhisperS2TService.cs b/Journal.Sidecar/LocalWhisperS2TService.cs new file mode 100644 index 0000000..b53ecb9 --- /dev/null +++ b/Journal.Sidecar/LocalWhisperS2TService.cs @@ -0,0 +1,307 @@ +using System.Collections.Concurrent; +using Journal.Core.Dtos; +using Journal.Core.Services.Speech; +using NAudio.Wave; +using Whisper.net; +using Whisper.net.Ggml; + +namespace Journal.Sidecar; + +public sealed class LocalWhisperS2TService : IS2TService, IDisposable +{ + private const int SampleRate = 16000; + private const int Bits = 16; + private const int Channels = 1; + private const int ChunkMs = 2000; + private const int MaxBufferedItems = 256; + private const int SilenceRmsThreshold = 150; + + private readonly Lock _sync = new(); + private readonly Lock _segmentLock = new(); + private readonly ConcurrentQueue<string> _transcripts = new(); + + private WaveInEvent? _waveIn; + private System.Timers.Timer? _flushTimer; + private MemoryStream? _segmentBuffer; + private BlockingCollection<byte[]>? _chunkQueue; + private CancellationTokenSource? _cts; + private Task? _worker; + private WhisperFactory? _factory; + private volatile bool _running; + private string _status = "stopped"; + private string? _warning; + + public async Task<S2TStartResultDto> StartAsync(CancellationToken cancellationToken = default) + { + lock (_sync) + { + if (_running) + return new S2TStartResultDto(true, _status, _warning); + _status = "starting"; + _warning = null; + } + + try + { + var modelPath = await EnsureModelAsync(cancellationToken); + lock (_sync) + { + _factory ??= WhisperFactory.FromPath(modelPath); + _segmentBuffer = new MemoryStream(); + _chunkQueue = []; + _cts = new CancellationTokenSource(); + + var waveFormat = new WaveFormat(SampleRate, Bits, Channels); + _waveIn = new WaveInEvent + { + DeviceNumber = -1, + WaveFormat = waveFormat, + BufferMilliseconds = 100 + }; + _waveIn.DataAvailable += HandleDataAvailable; + + _flushTimer = new System.Timers.Timer(ChunkMs); + _flushTimer.Elapsed += (_, _) => FlushChunk(waveFormat); + + _worker = Task.Run(() => RunWorkerAsync(waveFormat), _cts.Token); + _waveIn.StartRecording(); + _flushTimer.Start(); + _running = true; + _status = "listening"; + } + } + catch (Exception ex) + { + lock (_sync) + { + _running = false; + _status = "error"; + _warning = ex.Message; + } + return new S2TStartResultDto(false, "error", ex.Message); + } + + return new S2TStartResultDto(true, "listening"); + } + + public async Task<S2TStopResultDto> StopAsync(CancellationToken cancellationToken = default) + { + WaveInEvent? waveIn; + System.Timers.Timer? flushTimer; + BlockingCollection<byte[]>? queue; + CancellationTokenSource? cts; + Task? worker; + + lock (_sync) + { + if (!_running) + return new S2TStopResultDto(false, "stopped", _warning); + + _running = false; + _status = "stopped"; + waveIn = _waveIn; + flushTimer = _flushTimer; + queue = _chunkQueue; + cts = _cts; + worker = _worker; + + _waveIn = null; + _flushTimer = null; + _chunkQueue = null; + _cts = null; + _worker = null; + } + + try + { + flushTimer?.Stop(); + if (waveIn is not null) + { + waveIn.DataAvailable -= HandleDataAvailable; + waveIn.StopRecording(); + waveIn.Dispose(); + } + queue?.CompleteAdding(); + cts?.Cancel(); + if (worker is not null) + { + await Task.WhenAny(worker, Task.Delay(1000, cancellationToken)); + } + } + finally + { + flushTimer?.Dispose(); + cts?.Dispose(); + lock (_sync) + { + _segmentBuffer?.Dispose(); + _segmentBuffer = null; + } + } + + return new S2TStopResultDto(false, "stopped"); + } + + public Task<S2TPollResultDto> PollAsync(int maxItems = 8, CancellationToken cancellationToken = default) + { + if (maxItems <= 0) + maxItems = 1; + if (maxItems > 64) + maxItems = 64; + + var items = new List<string>(maxItems); + while (items.Count < maxItems && _transcripts.TryDequeue(out var text)) + { + items.Add(text); + } + + return Task.FromResult(new S2TPollResultDto(items, _running, _status, _warning)); + } + + private void HandleDataAvailable(object? sender, WaveInEventArgs e) + { + lock (_segmentLock) + { + _segmentBuffer?.Write(e.Buffer, 0, e.BytesRecorded); + } + } + + private void FlushChunk(WaveFormat waveFormat) + { + var queue = _chunkQueue; + if (queue is null || queue.IsAddingCompleted) + return; + + byte[]? chunk = null; + lock (_segmentLock) + { + if (_segmentBuffer is null) + return; + if (_segmentBuffer.Length < waveFormat.AverageBytesPerSecond / 2) + return; + chunk = _segmentBuffer.ToArray(); + _segmentBuffer.SetLength(0); + } + + if (chunk is not null && chunk.Length > 0) + queue.Add(chunk); + } + + private async Task RunWorkerAsync(WaveFormat waveFormat) + { + try + { + var queue = _chunkQueue; + if (queue is null || _factory is null) + return; + + using var processor = _factory.CreateBuilder() + .WithLanguage("en") + .Build(); + + foreach (var pcmChunk in queue.GetConsumingEnumerable()) + { + try + { + if (IsLikelySilence(pcmChunk)) + continue; + + using var pcmStream = new MemoryStream(pcmChunk, writable: false); + using var raw = new RawSourceWaveStream(pcmStream, waveFormat); + using var wavStream = new MemoryStream(); + WaveFileWriter.WriteWavFileToStream(wavStream, raw); + wavStream.Position = 0; + + await foreach (var result in processor.ProcessAsync(wavStream)) + { + var text = result.Text?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + continue; + if (IsPlaceholderTranscript(text)) + continue; + EnqueueTranscript(text); + } + } + catch (Exception ex) + { + _warning = $"Transcription error: {ex.Message}"; + } + } + } + catch (Exception ex) + { + lock (_sync) + { + _status = "error"; + _warning = ex.Message; + } + } + } + + private static async Task<string> EnsureModelAsync(CancellationToken cancellationToken) + { + var modelDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ProjectJournal", + "speech-models"); + Directory.CreateDirectory(modelDirectory); + var modelPath = Path.Combine(modelDirectory, "ggml-base.en.bin"); + if (File.Exists(modelPath)) + return modelPath; + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(5)); + using var modelStream = await WhisperGgmlDownloader.Default.GetGgmlModelAsync( + GgmlType.BaseEn, + cancellationToken: cts.Token); + using var fileWriter = File.OpenWrite(modelPath); + await modelStream.CopyToAsync(fileWriter, cts.Token); + return modelPath; + } + + private static bool IsLikelySilence(byte[] pcmChunk) + { + if (pcmChunk.Length < 2) + return true; + + long sumSquares = 0; + int samples = pcmChunk.Length / 2; + for (int i = 0; i + 1 < pcmChunk.Length; i += 2) + { + short sample = (short)(pcmChunk[i] | (pcmChunk[i + 1] << 8)); + sumSquares += (long)sample * sample; + } + + if (samples <= 0) + return true; + + var rms = Math.Sqrt(sumSquares / (double)samples); + return rms < SilenceRmsThreshold; + } + + private static bool IsPlaceholderTranscript(string text) + { + var normalized = text.Trim(); + if (!(normalized.StartsWith('[') && normalized.EndsWith(']'))) + return false; + + return normalized.Equals("[BLANK_AUDIO]", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("[NO AUDIO]", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("[SILENCE]", StringComparison.OrdinalIgnoreCase); + } + + private void EnqueueTranscript(string text) + { + _transcripts.Enqueue(text); + while (_transcripts.Count > MaxBufferedItems && _transcripts.TryDequeue(out _)) + { + } + } + + public void Dispose() + { + StopAsync().GetAwaiter().GetResult(); + _factory?.Dispose(); + _factory = null; + } +} diff --git a/Journal.SmokeTests/Fixtures/transport_cases.json b/Journal.SmokeTests/Fixtures/transport_cases.json new file mode 100644 index 0000000..b6c84dd --- /dev/null +++ b/Journal.SmokeTests/Fixtures/transport_cases.json @@ -0,0 +1,50 @@ +[ + { + "name": "List returns array envelope", + "request": "{\"action\":\"fragments.list\"}", + "expectOk": true, + "dataKind": "array" + }, + { + "name": "Create returns object envelope", + "request": "{\"action\":\"fragments.create\",\"payload\":{\"type\":\"!NOTE\",\"description\":\"fixture create\"}}", + "expectOk": true, + "dataKind": "object" + }, + { + "name": "Get missing id returns null data", + "request": "{\"action\":\"fragments.get\",\"id\":\"00000000-0000-0000-0000-000000000001\"}", + "expectOk": true, + "dataKind": "null" + }, + { + "name": "Create missing payload fails", + "request": "{\"action\":\"fragments.create\"}", + "expectOk": false, + "errorContains": "payload" + }, + { + "name": "AI health returns object envelope", + "request": "{\"action\":\"ai.health\"}", + "expectOk": true, + "dataKind": "object" + }, + { + "name": "AI summarize entry returns string envelope", + "request": "{\"action\":\"ai.summarize_entry\",\"payload\":{\"content\":\"transport test\"}}", + "expectOk": true, + "dataKind": "string" + }, + { + "name": "Unknown action fails", + "request": "{\"action\":\"unknown.action\"}", + "expectOk": false, + "errorContains": "Unknown action" + }, + { + "name": "Malformed JSON fails", + "request": "{\"action\":\"fragments.list\"", + "expectOk": false, + "errorContains": "Invalid command JSON" + } +] diff --git a/Journal.SmokeTests/GlobalUsings.cs b/Journal.SmokeTests/GlobalUsings.cs new file mode 100644 index 0000000..7ad9a11 --- /dev/null +++ b/Journal.SmokeTests/GlobalUsings.cs @@ -0,0 +1,19 @@ +global using System.ComponentModel.DataAnnotations; +global using System.IO.Compression; +global using System.Text.Json; +global using Journal.Core; +global using Journal.Core.Dtos; +global using Journal.Core.Models; +global using Journal.Core.Repositories; +global using Journal.Core.Services.Ai; +global using Journal.Core.Services.Config; +global using Journal.Core.Services.Database; +global using Journal.Core.Services.Entries; +global using Journal.Core.Services.Fragments; +global using Journal.Core.Services.Logging; +global using Journal.Core.Services.Sidecar; +global using Journal.Core.Services.Speech; +global using Journal.Core.Services.Lists; +global using Journal.Core.Services.Todos; +global using Journal.Core.Services.Conversations; +global using Journal.Core.Services.Vault; diff --git a/Journal.SmokeTests/Journal.SmokeTests.csproj b/Journal.SmokeTests/Journal.SmokeTests.csproj new file mode 100644 index 0000000..0b5dedd --- /dev/null +++ b/Journal.SmokeTests/Journal.SmokeTests.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\Journal.Core\Journal.Core.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="Fixtures\*.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + +</Project> diff --git a/Journal.SmokeTests/Program.AiSpeechTests.cs b/Journal.SmokeTests/Program.AiSpeechTests.cs new file mode 100644 index 0000000..bd80ced --- /dev/null +++ b/Journal.SmokeTests/Program.AiSpeechTests.cs @@ -0,0 +1,134 @@ +internal static partial class Program +{ + static async Task TestEntryAiHealthDefaultAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"ai.health"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for ai.health."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetProperty("Enabled").GetBoolean() is false, "Expected AI disabled by default."); + Assert(string.Equals(data.GetProperty("Provider").GetString(), "none", StringComparison.OrdinalIgnoreCase), "Expected default provider 'none'."); + } + + static async Task TestEntryAiSummarizeEntryDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.summarize_entry", + payload = new + { + content = "sample entry" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_entry."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_entry."); + } + + static async Task TestEntryAiSummarizeAllDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.summarize_all", + payload = new + { + entries = new[] { "entry one", "entry two" } + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_all."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_all."); + } + + static async Task TestEntryAiChatDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.chat", + payload = new + { + prompt = "hello cloud" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.chat."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.chat."); + } + + static async Task TestEntryAiEmbedDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.embed", + payload = new + { + content = "embedding source text" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.embed."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected ai.embed response to be a JSON array."); + Assert(data.GetArrayLength() == 0, "Expected disabled ai.embed to return an empty vector."); + } + + static async Task TestEntrySpeechDevicesListDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "speech.devices.list", + payload = new { } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.devices.list when disabled."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.devices.list data to be an object."); + } + + static async Task TestEntrySpeechTranscribeDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "speech.transcribe", + payload = new + { + text = "fixture transcript", + engine = "whisper" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.transcribe when disabled."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.transcribe data to be an object."); + var warning = data.TryGetProperty("Warning", out var warningNode) ? warningNode.GetString() ?? "" : ""; + Assert(warning.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled speech warning."); + } +} + diff --git a/Journal.SmokeTests/Program.DatabaseConfigTests.cs b/Journal.SmokeTests/Program.DatabaseConfigTests.cs new file mode 100644 index 0000000..0c260e8 --- /dev/null +++ b/Journal.SmokeTests/Program.DatabaseConfigTests.cs @@ -0,0 +1,188 @@ +internal static partial class Program +{ + static Task TestDatabaseKeyDerivationMatchesPythonAsync() + { + var service = NewDatabaseService(); + var keyHex = Convert.ToHexString(service.DeriveDatabaseKey("vault-pass-123")).ToLowerInvariant(); + var expected = "6a9de08e13357aa8f14e7eb0ccde119e7b4d277c60aaaca6493d9a1e1eaa5b04"; + Assert(keyHex == expected, "Database key derivation should match Python PBKDF2 fixture."); + return Task.CompletedTask; + } + + static Task TestDatabaseSchemaParityAsync() + { + var service = NewDatabaseService(); + var statements = service.GetSchemaStatements(); + var tableNames = new HashSet<string>(statements.Keys, StringComparer.OrdinalIgnoreCase); + + Assert(tableNames.Contains("entries"), "Schema should contain entries table."); + Assert(tableNames.Contains("sections"), "Schema should contain sections table."); + Assert(tableNames.Contains("fragments"), "Schema should contain fragments table."); + Assert(tableNames.Contains("tags"), "Schema should contain tags table."); + Assert(tableNames.Contains("fragment_tags"), "Schema should contain fragment_tags table."); + Assert(tableNames.Contains("entry_documents"), "Schema should contain entry_documents table."); + + var fragmentTagsSql = statements["fragment_tags"]; + Assert(fragmentTagsSql.Contains("PRIMARY KEY (fragment_id, tag_id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should enforce composite primary key parity."); + Assert(fragmentTagsSql.Contains("FOREIGN KEY (fragment_id) REFERENCES fragments (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain fragment foreign key parity."); + Assert(fragmentTagsSql.Contains("FOREIGN KEY (tag_id) REFERENCES tags (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain tag foreign key parity."); + + return Task.CompletedTask; + } + + static async Task TestEntryDatabaseStatusAsync() + { + var entry = NewEntry(unlocked: false); + var request = JsonSerializer.Serialize(new + { + action = "db.status", + payload = new + { + password = "vault-pass-123" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.status."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in db.status payload."); + Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath."); + Assert(data.TryGetProperty("KeyDerivation", out var keyDerivation), "Expected KeyDerivation in db.status payload."); + Assert(string.Equals(keyDerivation.GetString(), "PBKDF2-HMAC-SHA256", StringComparison.Ordinal), "Expected PBKDF2-HMAC-SHA256 key derivation."); + Assert(data.TryGetProperty("SchemaTables", out var schemaTables), "Expected SchemaTables list in db.status payload."); + Assert(schemaTables.ValueKind == JsonValueKind.Array && schemaTables.GetArrayLength() >= 5, "Expected schema table list in db.status payload."); + Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in db.status payload."); + Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected SQLCipher runtime-ready status in db.status payload."); + } + + static async Task TestEntryDatabaseInitializeSchemaAsync() + { + var entry = NewEntry(unlocked: false); + var request = JsonSerializer.Serialize(new + { + action = "db.initialize_schema", + payload = new + { + password = "vault-pass-123" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.initialize_schema."); + + var data = doc.RootElement.GetProperty("data"); + Assert(data.TryGetProperty("initialized", out var initialized), "Expected initialized flag in db.initialize_schema response."); + Assert(initialized.ValueKind == JsonValueKind.True, "Expected initialized=true from db.initialize_schema."); + Assert(data.TryGetProperty("databasePath", out var databasePath), "Expected databasePath in db.initialize_schema response."); + Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty databasePath from db.initialize_schema."); + } + + static async Task TestEntryDatabaseHydrateWorkspaceAsync() + { + var entry = NewEntry(unlocked: false); + var request = JsonSerializer.Serialize(new + { + action = "db.hydrate_workspace", + payload = new + { + password = "vault-pass-123" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.hydrate_workspace."); + var data = doc.RootElement.GetProperty("data"); + + Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in hydrate payload."); + Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath."); + Assert(data.TryGetProperty("EntryFilesProcessed", out var filesProcessed), "Expected EntryFilesProcessed in hydrate payload."); + Assert(filesProcessed.ValueKind == JsonValueKind.Number && filesProcessed.GetInt32() >= 0, "Expected non-negative EntryFilesProcessed in hydrate payload."); + Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in hydrate payload."); + Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected RuntimeReady=true when SQLCipher runtime hydration succeeds."); + } + + static Task TestConfigServiceParityKeysAsync() + { + IJournalConfigService config = NewConfigService(); + var current = config.Current; + + Assert(!string.IsNullOrWhiteSpace(current.ProjectRoot), "Config ProjectRoot should not be empty."); + Assert(!string.IsNullOrWhiteSpace(current.AppDirectory), "Config AppDirectory should not be empty."); + Assert(!string.IsNullOrWhiteSpace(current.VaultDirectory), "Config VaultDirectory should not be empty."); + Assert(!string.IsNullOrWhiteSpace(current.DatabaseFilename), "Config DatabaseFilename should not be empty."); + + Assert(current.LlamaCppUrl == "http://127.0.0.1:8085/v1/completions", "Config LlamaCppUrl default mismatch."); + Assert(current.LlamaCppModel == "qwen/qwen3-4b", "Config LlamaCppModel default mismatch."); + Assert(current.EmbeddingApiUrl == "http://127.0.0.1:8086/v1/embeddings", "Config EmbeddingApiUrl default mismatch."); + Assert(current.SpeechRecognitionEngine == "whisper", "Config SpeechRecognitionEngine default mismatch."); + Assert(current.WhisperModelSize == "base", "Config WhisperModelSize default mismatch."); + Assert(current.AiProvider == "llamasharp", "Config AiProvider default mismatch."); + + return Task.CompletedTask; + } + + static async Task TestEntryConfigGetAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"config.get"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for config.get."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get."); + + Assert(data.TryGetProperty("VaultDirectory", out var vaultDirectory), "Expected VaultDirectory in config payload."); + Assert(vaultDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(vaultDirectory.GetString()), "Expected non-empty VaultDirectory value."); + Assert(data.TryGetProperty("DatabaseFilename", out var databaseFilename), "Expected DatabaseFilename in config payload."); + Assert(databaseFilename.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databaseFilename.GetString()), "Expected non-empty DatabaseFilename value."); + Assert(data.TryGetProperty("LlamaCppUrl", out _), "Expected LlamaCppUrl in config payload."); + Assert(data.TryGetProperty("SpeechRecognitionEngine", out _), "Expected SpeechRecognitionEngine in config payload."); + } + + static Task TestLogRedactorScrubsSensitiveFieldsAsync() + { + var payload = JsonSerializer.SerializeToElement(new + { + password = "vault-pass-123", + content = "private journal body", + prompt = "private ai prompt", + nested = new + { + token = "abc123" + } + }); + + var redacted = LogRedactor.RedactPayload(payload); + var serialized = JsonSerializer.Serialize(redacted); + + Assert(!serialized.Contains("vault-pass-123", StringComparison.Ordinal), "Password should be redacted."); + Assert(!serialized.Contains("private journal body", StringComparison.Ordinal), "Entry content should be redacted."); + Assert(!serialized.Contains("private ai prompt", StringComparison.Ordinal), "Prompt should be redacted."); + Assert(!serialized.Contains("abc123", StringComparison.Ordinal), "Nested token should be redacted."); + Assert(serialized.Contains("[REDACTED]", StringComparison.Ordinal), "Redacted marker should be present."); + + return Task.CompletedTask; + } + + static Task TestLogRedactorPreservesNonSensitiveFieldsAsync() + { + var payload = JsonSerializer.SerializeToElement(new + { + action = "entries.save", + mode = "Daily", + filePath = "db://entry/2026-02-24.md" + }); + + var redacted = LogRedactor.RedactPayload(payload); + var serialized = JsonSerializer.Serialize(redacted); + + Assert(serialized.Contains("entries.save", StringComparison.Ordinal), "Non-sensitive action field should be preserved."); + Assert(serialized.Contains("Daily", StringComparison.Ordinal), "Non-sensitive mode field should be preserved."); + Assert(serialized.Contains("2026-02-24.md", StringComparison.Ordinal), "Non-sensitive path field should be preserved."); + + return Task.CompletedTask; + } +} diff --git a/Journal.SmokeTests/Program.EntryTests.cs b/Journal.SmokeTests/Program.EntryTests.cs new file mode 100644 index 0000000..4671959 --- /dev/null +++ b/Journal.SmokeTests/Program.EntryTests.cs @@ -0,0 +1,622 @@ +internal static partial class Program +{ + static async Task TestEntryUnknownActionAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"unknown.action"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Unknown action"), "Expected unknown action error."); + } + + static async Task TestEntryInvalidJsonAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("{\"action\":\"fragments.list\""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Invalid command JSON"), "Expected invalid JSON error."); + } + + static async Task TestEntryGetMissingReturnsNullDataAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "fragments.get", + id = Guid.NewGuid().ToString(), + }); + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true."); + Assert(doc.RootElement.GetProperty("data").ValueKind == JsonValueKind.Null, "Expected data=null for missing fragment."); + } + + static async Task TestEntryCreateMissingPayloadAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"fragments.create"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("payload", StringComparison.OrdinalIgnoreCase), "Expected payload validation error."); + } + + static async Task TestEntryEntriesSaveMergeAsync() + { + var entry = NewEntry(); + var firstPath = await SaveEntryForTestAsync(entry, "2026-02-22", """ +Date: 2026-02-22 +## Summary +old summary text +"""); + + var mergeRequest = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + filePath = firstPath, + mode = "Daily", + content = """ +Date: 2026-02-22 +## Summary +new summary text +## Reflection +new reflection text +""" + } + }); + + var mergeResponse = await entry.HandleCommandAsync(mergeRequest); + using var mergeDoc = JsonDocument.Parse(mergeResponse); + Assert(mergeDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save daily merge."); + + var loadedAfterMerge = await LoadEntryForTestAsync(entry, firstPath); + Assert(loadedAfterMerge.Contains("new summary text", StringComparison.Ordinal), "Expected merged entry to contain new summary text."); + Assert(!loadedAfterMerge.Contains("old summary text", StringComparison.Ordinal), "Expected merged entry to replace old summary section."); + Assert(loadedAfterMerge.Contains("new reflection text", StringComparison.Ordinal), "Expected merged entry to contain new reflection section."); + + var fragmentSaveRequest = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + filePath = firstPath, + mode = "Fragment", + content = "!NOTE\nfragment append text" + } + }); + + var fragmentResponse = await entry.HandleCommandAsync(fragmentSaveRequest); + using var fragmentDoc = JsonDocument.Parse(fragmentResponse); + Assert(fragmentDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save fragment mode."); + + var loadedAfterFragment = await LoadEntryForTestAsync(entry, firstPath); + Assert(loadedAfterFragment.Contains("fragment append text", StringComparison.Ordinal), "Expected fragment append text in saved entry."); + } + + static async Task TestEntryEntriesLoadAsync() + { + var entry = NewEntry(); + var content = """ +Date: 2026-02-22 +## Summary +hello world +"""; + var filePath = await SaveEntryForTestAsync(entry, "2026-02-22", content); + + var request = JsonSerializer.Serialize(new + { + action = "entries.load", + payload = new + { + filePath + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.load."); + + var data = doc.RootElement.GetProperty("data"); + var entryDto = data.GetProperty("Entry"); + Assert(entryDto.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load."); + Assert(entryDto.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load."); + Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load."); + } + + static async Task TestEntryEntriesListAsync() + { + var entry = NewEntry(); + await SaveEntryForTestAsync(entry, "2026-02-03", "c"); + await SaveEntryForTestAsync(entry, "2026-02-01", "a"); + + var request = JsonSerializer.Serialize(new + { + action = "entries.list", + payload = new { } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list."); + + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected entries.list data array."); + Assert(data.GetArrayLength() == 2, "Expected entries.list to return seeded markdown entries."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Expected entries.list sort order by file name."); + Assert(data[1].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected entries.list sort order by file name."); + } + + static async Task TestEntryTemplatesCrudExcludesFromEntriesListAsync() + { + var entry = NewEntry(); + await SaveEntryForTestAsync(entry, "2026-02-03", "daily entry"); + + var saveRequest = JsonSerializer.Serialize(new + { + action = "templates.save", + payload = new + { + name = "Weekly Review", + content = "# Weekly Review\n\n## Wins\n- one" + } + }); + var saveResponse = await entry.HandleCommandAsync(saveRequest); + using var saveDoc = JsonDocument.Parse(saveResponse); + Assert(saveDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.save."); + + var templatePath = saveDoc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? ""; + Assert(templatePath.EndsWith("Weekly%20Review.template.md", StringComparison.OrdinalIgnoreCase), "Template path should be canonical db://template path."); + + var listTemplatesRequest = JsonSerializer.Serialize(new + { + action = "templates.list", + payload = new { } + }); + var listTemplatesResponse = await entry.HandleCommandAsync(listTemplatesRequest); + using var listTemplatesDoc = JsonDocument.Parse(listTemplatesResponse); + Assert(listTemplatesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.list."); + var templateItems = listTemplatesDoc.RootElement.GetProperty("data"); + Assert(templateItems.GetArrayLength() == 1, "Expected one template in templates.list."); + Assert(templateItems[0].GetProperty("FileName").GetString() == "Weekly Review.template.md", "Template file name mismatch."); + + var listEntriesRequest = JsonSerializer.Serialize(new + { + action = "entries.list", + payload = new { } + }); + var listEntriesResponse = await entry.HandleCommandAsync(listEntriesRequest); + using var listEntriesDoc = JsonDocument.Parse(listEntriesResponse); + Assert(listEntriesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list."); + var entryItems = listEntriesDoc.RootElement.GetProperty("data"); + Assert(entryItems.GetArrayLength() == 1, "Expected entries.list to exclude template files."); + Assert(entryItems[0].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected only daily entry file in entries.list."); + + var loadTemplateRequest = JsonSerializer.Serialize(new + { + action = "templates.load", + payload = new + { + filePath = templatePath + } + }); + var loadTemplateResponse = await entry.HandleCommandAsync(loadTemplateRequest); + using var loadTemplateDoc = JsonDocument.Parse(loadTemplateResponse); + Assert(loadTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.load."); + var content = loadTemplateDoc.RootElement.GetProperty("data").GetProperty("Content").GetString() ?? ""; + Assert(content.Contains("## Wins", StringComparison.Ordinal), "Expected template content in templates.load result."); + + var deleteTemplateRequest = JsonSerializer.Serialize(new + { + action = "templates.delete", + payload = new + { + filePath = templatePath + } + }); + var deleteTemplateResponse = await entry.HandleCommandAsync(deleteTemplateRequest); + using var deleteTemplateDoc = JsonDocument.Parse(deleteTemplateResponse); + Assert(deleteTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.delete."); + Assert(deleteTemplateDoc.RootElement.GetProperty("data").GetBoolean(), "Expected templates.delete to return true."); + } + + static async Task TestEntrySearchEntriesMatchesRawContentAsync() + { + var entry = NewEntry(); + await SeedSearchFixtureEntriesAsync(entry); + + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + query = "common token", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries."); + Assert(data.GetArrayLength() == 2, "Expected two entries matching query across raw content."); + } + + static async Task TestEntrySearchEntriesWithoutQueryReturnsAllAsync() + { + var entry = NewEntry(); + await SeedSearchFixtureEntriesAsync(entry); + + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new { } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries without query."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries."); + Assert(data.GetArrayLength() == 3, "Expected all seeded markdown entries to be returned when query is omitted."); + } + + static async Task TestEntrySearchEntriesDateRangeFilterAsync() + { + var entry = NewEntry(); + await SeedSearchFixtureEntriesAsync(entry); + + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + startDate = "2026-02-02", + endDate = "2026-02-28", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for date-range filtered search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 1, "Expected one result for filtered date range."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-05.md", "Date-range result mismatch."); + } + + static async Task TestEntrySearchEntriesSectionFilterAsync() + { + var entry = NewEntry(); + await SeedSearchFixtureEntriesAsync(entry); + + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + query = "focus area", + section = "Reflection", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for section-scoped search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 1, "Expected one section-scoped result."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Section filter result mismatch."); + } + + static async Task TestEntrySearchEntriesTagTypeFilterAsync() + { + var entry = NewEntry(); + await SeedSearchFixtureEntriesAsync(entry); + + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + tags = new[] { "stress" }, + types = new[] { "!TRIGGER" }, + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for fragment tag/type filtered search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 1, "Expected one result for fragment tag/type filters."); + Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Tag/type filter result mismatch."); + } + + static async Task TestEntrySearchEntriesCheckboxFilterAsync() + { + var entry = NewEntry(); + await SeedSearchFixtureEntriesAsync(entry); + + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + @checked = new[] { "med taken" }, + @unchecked = new[] { "drink water" }, + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for checkbox filtered search."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetArrayLength() == 2, "Expected OR-style checkbox match across checked/unchecked filters."); + } + + static async Task TestEntrySearchEntriesRejectsInvalidDateAsync() + { + var entry = NewEntry(); + await SeedSearchFixtureEntriesAsync(entry); + + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + startDate = "2026/02/01", + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false for invalid date format."); + var error = doc.RootElement.GetProperty("error").GetString() ?? ""; + Assert(error.Contains("invalid startdate value", StringComparison.OrdinalIgnoreCase), "Expected invalid startDate error."); + } + + static Task TestSidecarSearchCliFilteredAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + + using var session = new DatabaseSessionService(dbService); + session.SetPassword("vault-pass-123"); + + var repo = new SqliteEntryFileRepository(session); + var entryFiles = new EntryFileService(repo); + var searchService = new EntrySearchService(repo); + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles); + + try + { + entryFiles.SaveEntry(new EntrySavePayload(""" +Date: 2026-02-01 +## Summary +common +- [x] med taken +!TRIGGER #stress +matched body +""", Mode: "Overwrite", FileName: "2026-02-01")); + entryFiles.SaveEntry(new EntrySavePayload(""" +Date: 2026-02-05 +## Summary +common +- [ ] drink water +!NOTE #daily +non match +""", Mode: "Overwrite", FileName: "2026-02-05")); + + var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand( + [ + "common", + "--start-date", "2026-02-01", + "--end-date", "2026-02-28", + "--tag", "stress", + "--type", "!TRIGGER", + "--checked", "med taken", + "--section", "Summary" + ])); + + Assert(exitCode == 0, "Expected search CLI command to succeed."); + Assert(string.IsNullOrWhiteSpace(stderr), "Expected no stderr output for successful search CLI command."); + Assert(stdout.Contains("--- 2026-02-01 ---", StringComparison.Ordinal), "Expected matching entry header in search CLI output."); + Assert(!stdout.Contains("--- 2026-02-05 ---", StringComparison.Ordinal), "Unexpected non-matching entry in filtered search CLI output."); + } + finally + { + session.Dispose(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestSidecarSearchCliEmptyDataAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + + using var session = new DatabaseSessionService(dbService); + session.SetPassword("vault-pass-123"); + + var repo = new SqliteEntryFileRepository(session); + var entryFiles = new EntryFileService(repo); + var searchService = new EntrySearchService(repo); + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles); + + try + { + var (exitCode, stdout, _) = CaptureConsole(() => cli.RunSearchCommand([])); + + Assert(exitCode == 0, "Expected search CLI command to return success for empty data store."); + Assert(stdout.Contains("No journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message."); + } + finally + { + session.Dispose(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestEntrySavePayloadFileNameDeserializationAsync() + { + var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}"""; + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var element = JsonSerializer.Deserialize<JsonElement>(json); + var payload = element.Deserialize<EntrySavePayload>(options); + + Assert(payload is not null, "Payload should not be null."); + Assert(payload!.Content == "hello", "Content should be deserialized."); + Assert(payload.Mode == "Overwrite", "Mode should be deserialized."); + Assert(payload.FileName == "My Custom Name", "FileName should be deserialized from camelCase JSON via JsonElement."); + Assert(payload.FilePath is null, "FilePath should be null when not provided."); + + return Task.CompletedTask; + } + + static async Task TestEntrySaveWithFileNameAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + + using var session = new DatabaseSessionService(dbService); + session.SetPassword("vault-pass-123"); + + var repo = new SqliteEntryFileRepository(session); + var service = new EntryFileService(repo); + + try + { + var payload = new EntrySavePayload( + Content: "# Custom Entry\n\nHello world", + FilePath: null, + Mode: "Overwrite", + FileName: "My Custom Name"); + + var result = service.SaveEntry(payload); + + Assert(result.FilePath.StartsWith("db://entry/", StringComparison.OrdinalIgnoreCase), "Expected canonical db://entry path."); + Assert(result.FilePath.Contains("My%20Custom%20Name.md", StringComparison.OrdinalIgnoreCase), "Expected escaped custom name in db path."); + + var loaded = service.LoadEntry(result.FilePath); + Assert(loaded.Entry.RawContent.Contains("Hello world", StringComparison.Ordinal), "Stored content should match."); + + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + content = "# Second Entry", + mode = "Overwrite", + fileName = "Another Custom Name" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save with fileName."); + + var savedFilePath = doc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? ""; + Assert(savedFilePath.Contains("Another%20Custom%20Name.md", StringComparison.OrdinalIgnoreCase), + $"Expected file path to contain 'Another%20Custom%20Name.md' but got '{savedFilePath}'."); + } + finally + { + session.Dispose(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static async Task<string> SaveEntryForTestAsync(Entry entry, string fileStem, string content) + { + var request = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + content, + mode = "Overwrite", + fileName = fileStem + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for test seed entries.save."); + return doc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? ""; + } + + private static async Task<string> LoadEntryForTestAsync(Entry entry, string filePath) + { + var request = JsonSerializer.Serialize(new + { + action = "entries.load", + payload = new + { + filePath + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for test entries.load."); + return doc.RootElement.GetProperty("data").GetProperty("Entry").GetProperty("RawContent").GetString() ?? ""; + } + + private static async Task SeedSearchFixtureEntriesAsync(Entry entry) + { + await SaveEntryForTestAsync(entry, "2026-02-01", """ +Date: 2026-02-01 +## Summary +Alpha line +common token +## Reflection +focus area +- [x] med taken +!TRIGGER #stress +fragment one +"""); + + await SaveEntryForTestAsync(entry, "2026-02-05", """ +Date: 2026-02-05 +## Summary +beta line +COMMON token +## Reflection +other notes +- [ ] drink water +!NOTE #daily +fragment two +"""); + + await SaveEntryForTestAsync(entry, "2026-03-01", """ +Date: 2026-03-01 +## Summary +gamma only +## Reflection +nothing related +!NOTE #other +fragment three +"""); + } +} diff --git a/Journal.SmokeTests/Program.FragmentTests.cs b/Journal.SmokeTests/Program.FragmentTests.cs new file mode 100644 index 0000000..55987f5 --- /dev/null +++ b/Journal.SmokeTests/Program.FragmentTests.cs @@ -0,0 +1,231 @@ +internal static partial class Program +{ + static Task TestCreateTrimsAsync() + { + var service = NewService(); + var created = service.Create(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "])); + + Assert(created.Type == "!TRIGGER", "Type should be trimmed."); + Assert(created.Description == "stomach drop", "Description should be trimmed."); + Assert(created.Tags.Count == 2, "Expected two normalized tags."); + Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved."); + return Task.CompletedTask; + } + + static Task TestUpdateAcceptsTypeAsync() + { + var service = NewService(); + var created = service.Create(new CreateFragmentDto("!TRIGGER", "one")); + var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); + + Assert(ok, "Expected update to succeed."); + var updated = service.GetById(created.Id); + Assert(updated is not null, "Updated fragment should exist."); + Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored."); + Assert(updated.Description == "two", "Updated description should be trimmed and stored."); + Assert(updated.Tags.Count == 1 && updated.Tags[0] == "memory", "Updated tags should be normalized."); + return Task.CompletedTask; + } + + static Task TestUpdateRejectsWhitespaceTypeAsync() + { + var service = NewService(); + var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc")); + + try + { + _ = service.Update(created.Id, new UpdateFragmentDto(Type: " ")); + } + catch (ValidationException) + { + return Task.CompletedTask; + } + + throw new InvalidOperationException("Expected ValidationException for whitespace type update."); + } + + static Task TestFileRepositoryPersistsAsync() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(tempRoot, "data"); + Directory.CreateDirectory(dataDir); + const string password = "smoke-test-password"; + + try + { + var configService = NewConfigService(tempRoot); + var dbService = new JournalDatabaseService(configService); + + // First session: create a fragment + using var session1 = new DatabaseSessionService(dbService); + session1.SetPassword(password); + var repo1 = new SqliteFragmentRepository(session1); + var service1 = new FragmentService(repo1); + var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); + + // Second session: verify persistence + using var session2 = new DatabaseSessionService(dbService); + session2.SetPassword(password); + var repo2 = new SqliteFragmentRepository(session2); + var service2 = new FragmentService(repo2); + var loaded = service2.GetById(created.Id); + + Assert(loaded is not null, "Expected fragment to persist across repository instances."); + Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch."); + Assert(loaded.Tags.Count == 1 && loaded.Tags[0] == "tag1", "Persisted tags mismatch."); + } + finally + { + if (Directory.Exists(tempRoot)) + Directory.Delete(tempRoot, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestJournalEntryModelAsync() + { + var fragment = new Fragment("!TRIGGER", "test fragment"); + var section = new ParsedSection( + "Summary", + content: ["line one", "- [x] completed thing"], + checkboxes: new Dictionary<string, bool> { ["completed thing"] = true }); + + var entry = new JournalEntry( + date: "2026-02-22", + fragments: [fragment], + rawContent: "raw markdown content", + sections: new Dictionary<string, ParsedSection> { ["Summary"] = section }); + + Assert(entry.Date == "2026-02-22", "JournalEntry date mismatch."); + Assert(entry.RawContent == "raw markdown content", "JournalEntry raw content mismatch."); + Assert(entry.Fragments.Count == 1, "JournalEntry fragment count mismatch."); + Assert(entry.Sections.Count == 1, "JournalEntry section count mismatch."); + Assert(entry.GetSection("Summary").Contains("line one"), "JournalEntry section content mismatch."); + Assert(entry.GetCheckboxState("Summary", "completed thing") is true, "JournalEntry checkbox state mismatch."); + Assert(entry.GetCheckboxState("Summary", "missing") is null, "JournalEntry checkbox should return null when missing."); + + return Task.CompletedTask; + } + + static Task TestMergeOverwritesMeaningfulSectionAsync() + { + var current = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection("Summary", ["old content"]) + }); + + var incoming = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection( + "Summary", + [" ", "new content line"], + new Dictionary<string, bool> { ["new check"] = true }), + ["Reflection"] = new ParsedSection("Reflection", ["reflective note"]) + }); + + current.MergeWith(incoming); + + Assert(current.GetSection("Summary").Contains("new content line"), "Meaningful section update should overwrite existing section."); + Assert(!current.GetSection("Summary").Contains("old content"), "Old section content should be replaced."); + Assert(current.GetCheckboxState("Summary", "new check") is true, "Overwritten section checkbox state should come from incoming section."); + Assert(current.GetSection("Reflection").Contains("reflective note"), "Meaningful new section should be added."); + + return Task.CompletedTask; + } + + static Task TestMergeIgnoresWhitespaceOnlySectionAsync() + { + var current = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection("Summary", ["keep existing"]) + }); + + var incoming = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Summary"] = new ParsedSection("Summary", [" ", "\t", ""]) + }); + + current.MergeWith(incoming); + + Assert(current.GetSection("Summary").Contains("keep existing"), "Whitespace-only section update should be ignored."); + + return Task.CompletedTask; + } + + static Task TestMergeAppendsNonDuplicateFragmentsAsync() + { + var current = new JournalEntry( + date: "2026-02-22", + fragments: + [ + new Fragment("!TRIGGER", "duplicate description") + ]); + + var incoming = new JournalEntry( + date: "2026-02-22", + fragments: + [ + new Fragment("!NOTE", "duplicate description"), + new Fragment("!NOTE", "new description") + ]); + + current.MergeWith(incoming); + + Assert(current.Fragments.Count == 2, "Expected only one new fragment to be appended."); + Assert(current.Fragments.Count(fragment => fragment.Description == "duplicate description") == 1, "Duplicate description should not be appended."); + Assert(current.Fragments.Any(fragment => fragment.Description == "new description"), "New fragment description should be appended."); + + return Task.CompletedTask; + } + + static Task TestToMarkdownCanonicalSectionOrderAsync() + { + var entry = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary<string, ParsedSection> + { + ["Reflection"] = new ParsedSection("Reflection", ["reflection body"]), + ["Summary"] = new ParsedSection("Summary", ["summary body"]) + }); + + var markdown = entry.ToMarkdown(); + var summaryIdx = markdown.IndexOf("## Summary", StringComparison.Ordinal); + var reflectionIdx = markdown.IndexOf("## Reflection", StringComparison.Ordinal); + + Assert(summaryIdx >= 0, "Summary header should be emitted."); + Assert(reflectionIdx >= 0, "Reflection header should be emitted."); + Assert(summaryIdx < reflectionIdx, "Sections should be emitted in canonical order."); + + return Task.CompletedTask; + } + + static Task TestToMarkdownFragmentFormattingAsync() + { + var fragment = new Fragment("!TRIGGER", "fragment body") + { + Time = default, + Tags = ["stress", "body"] + }; + var entry = new JournalEntry( + date: "2026-02-22", + fragments: [fragment]); + + var markdown = entry.ToMarkdown(); + + Assert(markdown.Contains("# Fragments\n", StringComparison.Ordinal), "Fragments header should be present."); + Assert(markdown.Contains("!TRIGGER #stress #body\nfragment body\n", StringComparison.Ordinal), "Fragment block format should match parity shape."); + Assert(markdown.Contains("**Date:** 2026-02-22", StringComparison.Ordinal), "Date frontmatter line should be present."); + + return Task.CompletedTask; + } +} + diff --git a/Journal.SmokeTests/Program.ParserTests.cs b/Journal.SmokeTests/Program.ParserTests.cs new file mode 100644 index 0000000..df34165 --- /dev/null +++ b/Journal.SmokeTests/Program.ParserTests.cs @@ -0,0 +1,153 @@ +internal static partial class Program +{ + static Task TestParserExtractsBoldDateAsync() + { + var content = """ + --- + type: journal + --- + **Date:** 2026-02-22 + ## Summary + hello + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + Assert(entry.Date == "2026-02-22", "Parser should read date from **Date:** marker."); + return Task.CompletedTask; + } + + static Task TestParserExtractsPlainDateAsync() + { + var content = """ + Date: 2026-02-23 + ## Summary + hello + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + Assert(entry.Date == "2026-02-23", "Parser should read date from Date: marker."); + return Task.CompletedTask; + } + + static Task TestParserFallsBackToFileStemAsync() + { + var content = """ + ## Summary + no explicit date + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-24"); + Assert(entry.Date == "2026-02-24", "Parser should fall back to file stem when no date marker is present."); + return Task.CompletedTask; + } + + static Task TestParserCapturesSectionsAsync() + { + var content = """ + Date: 2026-02-25 + ## Summary + line one + line two + ### Events / Triggers - Work + trigger line + ## reflection notes + anchor line + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.Sections.ContainsKey("Summary"), "Parser should capture Summary section."); + Assert(entry.Sections.ContainsKey("Events / Triggers"), "Parser should capture Events / Triggers section."); + Assert(entry.Sections.ContainsKey("Reflection"), "Parser should match canonical section title by substring."); + Assert(entry.GetSection("Summary").Contains("line one"), "Summary section content mismatch."); + Assert(entry.GetSection("Events / Triggers").Contains("trigger line"), "Events / Triggers section content mismatch."); + Assert(entry.GetSection("Reflection").Contains("anchor line"), "Reflection section content mismatch."); + + return Task.CompletedTask; + } + + static Task TestParserIgnoresNonCanonicalHeadersAsync() + { + var content = """ + ## Summary + keep this + ## Totally Custom Header + should not be captured + ### Events / Triggers + keep this too + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.GetSection("Summary").Contains("keep this"), "Summary section should be captured."); + Assert(!entry.GetSection("Summary").Contains("should not be captured"), "Non-canonical section content should not bleed into previous section."); + Assert(entry.GetSection("Events / Triggers").Contains("keep this too"), "Canonical section after custom header should be captured."); + + return Task.CompletedTask; + } + + static Task TestParserCapturesCheckboxStatesAsync() + { + var content = """ + ## Summary + - [x] took medication + - [ ] drank water + * [X] wrote reflection + ## Events / Triggers + - [ ] talked to manager + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.GetCheckboxState("Summary", "took medication") is true, "Expected checked state for '- [x]' checkbox."); + Assert(entry.GetCheckboxState("Summary", "drank water") is false, "Expected unchecked state for '- [ ]' checkbox."); + Assert(entry.GetCheckboxState("Summary", "wrote reflection") is true, "Expected checked state for '* [X]' checkbox."); + Assert(entry.GetCheckboxState("Events / Triggers", "talked to manager") is false, "Expected unchecked state in Events / Triggers section."); + Assert(entry.GetCheckboxState("Summary", "missing item") is null, "Missing checkbox text should return null."); + + return Task.CompletedTask; + } + + static Task TestParserCapturesMultilineFragmentsAsync() + { + var content = """ + Date: 2026-02-26 + ## Summary + text + !TRIGGER @2026-02-26T10:15:00Z #stress #body + first line + second line + !NOTE #daily + short note + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.Fragments.Count == 2, "Expected two parsed fragments."); + Assert(entry.Fragments[0].Type == "!TRIGGER", "First fragment type mismatch."); + Assert(entry.Fragments[0].Description == "first line\nsecond line", "First fragment multiline description mismatch."); + Assert(entry.Fragments[0].Tags.Count == 2, "First fragment tag count mismatch."); + Assert(entry.Fragments[0].Tags[0] == "stress" && entry.Fragments[0].Tags[1] == "body", "First fragment tags mismatch."); + Assert(entry.Fragments[1].Type == "!NOTE", "Second fragment type mismatch."); + Assert(entry.Fragments[1].Description == "short note", "Second fragment description mismatch."); + Assert(entry.Fragments[1].Tags.Count == 1 && entry.Fragments[1].Tags[0] == "daily", "Second fragment tags mismatch."); + + return Task.CompletedTask; + } + + static Task TestParserFragmentBoundaryBehaviorAsync() + { + var content = """ + !TRIGGER #a + line one + !NOTE this starts another fragment header + line two + """; + + var fragments = JournalParser.ParseFragments(content); + Assert(fragments.Count == 1, "Expected one parsed fragment because second boundary line is not a valid fragment header."); + Assert(fragments[0].Description == "line one", "First fragment boundary capture mismatch."); + return Task.CompletedTask; + } +} + diff --git a/Journal.SmokeTests/Program.Shared.cs b/Journal.SmokeTests/Program.Shared.cs new file mode 100644 index 0000000..25c8902 --- /dev/null +++ b/Journal.SmokeTests/Program.Shared.cs @@ -0,0 +1,198 @@ +internal static partial class Program +{ + static FragmentService NewService() + { + var config = NewConfigService(); + var dbService = new JournalDatabaseService(config); + var session = new DatabaseSessionService(dbService); + session.SetPassword("smoke-test-password"); + var repo = new SqliteFragmentRepository(session); + return new FragmentService(repo); + } + + static Entry NewEntry(bool unlocked = true, string password = "vault-pass-123", string? root = null) + { + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var session = new DatabaseSessionService(dbService); + if (unlocked) + session.SetPassword(password); + + var entryRepo = new SqliteEntryFileRepository(session); + + return new Entry( + NewService(), + new EntrySearchService(entryRepo), + new VaultStorageService(new VaultCryptoService(), dbService), + dbService, + session, + config, + new DisabledAiService("none"), + new DisabledSpeechBridgeService("none"), + new DisabledS2TService(), + new EntryFileService(entryRepo), + new ListService(new SqliteListRepository(session)), + new TodoService(new SqliteTodoRepository(session)), + new DisabledCoachService(), + new ConversationService(new SqliteConversationRepository(session)), + new CommandLogger()); + } + + static Entry NewLockedEntry() => NewEntry(unlocked: false); + + static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(NewConfigService()); + + static IJournalConfigService NewConfigService(string? root = null, string? databaseFilename = null) + { + var baseConfig = new JournalConfigService().Current; + var normalizedRoot = Path.GetFullPath(root ?? Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N"))); + var appDirectory = Path.Combine(normalizedRoot, "journal"); + var vaultDirectory = Path.Combine(appDirectory, "vault"); + var logDirectory = Path.Combine(normalizedRoot, "logs"); + + Directory.CreateDirectory(vaultDirectory); + Directory.CreateDirectory(logDirectory); + + var config = baseConfig with + { + ProjectRoot = normalizedRoot, + AppDirectory = appDirectory, + VaultDirectory = vaultDirectory, + LogDirectory = logDirectory, + PidFile = Path.Combine(logDirectory, "nicegui_server.pid"), + ServerControlFile = Path.Combine(logDirectory, "server_control.action"), + DatabaseFilename = databaseFilename ?? "journal_cache.db" + }; + + return new FixedConfigService(config); + } + + private sealed class FixedConfigService(JournalConfig config) : IJournalConfigService + { + public JournalConfig Current => config; + } + + static Dictionary<string, string> ReadVaultEntryTexts(string vaultPath, string password) + { + var crypto = new VaultCryptoService(); + var encrypted = File.ReadAllBytes(vaultPath); + var zipBytes = crypto.DecryptData(encrypted, password); + + using var stream = new MemoryStream(zipBytes); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var result = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + continue; + + using var reader = new StreamReader(entry.Open()); + result[entry.Name] = reader.ReadToEnd(); + } + + return result; + } + + static byte[] CreateZipBytes(Dictionary<string, string> files) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in files) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + } + return stream.ToArray(); + } + + static void WriteSearchFixtureFiles(string root) + { + File.WriteAllText(Path.Combine(root, "2026-02-01.md"), """ +Date: 2026-02-01 +## Summary +Alpha common +## Reflection +focus area +- [x] med taken +!TRIGGER #stress +fragment one +"""); + + File.WriteAllText(Path.Combine(root, "2026-02-05.md"), """ +Date: 2026-02-05 +## Summary +Beta common +## Reflection +other notes +- [ ] drink water +!NOTE #daily +fragment two +"""); + + File.WriteAllText(Path.Combine(root, "2026-03-01.md"), """ +Date: 2026-03-01 +## Summary +Gamma unique +## Reflection +nothing related +!NOTE #other +fragment three +"""); + } + + static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func<int> action) + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var exitCode = action(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + static async Task<List<TransportFixture>> LoadTransportFixturesAsync() + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "transport_cases.json"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Transport fixture file not found: {path}"); + + var json = await File.ReadAllTextAsync(path); + return JsonSerializer.Deserialize<List<TransportFixture>>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? []; + } + + static JsonValueKind ParseValueKind(string value) => value.Trim().ToLowerInvariant() switch + { + "array" => JsonValueKind.Array, + "object" => JsonValueKind.Object, + "null" => JsonValueKind.Null, + "string" => JsonValueKind.String, + "number" => JsonValueKind.Number, + "true" => JsonValueKind.True, + "false" => JsonValueKind.False, + _ => throw new InvalidOperationException($"Unsupported JsonValueKind '{value}' in transport fixture.") + }; + + static void Assert(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} + diff --git a/Journal.SmokeTests/Program.TransportTests.cs b/Journal.SmokeTests/Program.TransportTests.cs new file mode 100644 index 0000000..f24d5a7 --- /dev/null +++ b/Journal.SmokeTests/Program.TransportTests.cs @@ -0,0 +1,48 @@ +internal static partial class Program +{ + static async Task TestTransportFixturesAsync() + { + var fixtures = await LoadTransportFixturesAsync(); + Assert(fixtures.Count > 0, "Transport fixtures should not be empty."); + + foreach (var fixture in fixtures) + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync(fixture.Request); + + Assert(!response.Contains('\n') && !response.Contains('\r'), $"Fixture '{fixture.Name}' returned multiline output."); + + using var doc = JsonDocument.Parse(response); + var ok = doc.RootElement.GetProperty("ok").GetBoolean(); + Assert(ok == fixture.ExpectOk, $"Fixture '{fixture.Name}' expected ok={fixture.ExpectOk} but got ok={ok}."); + + if (fixture.ExpectOk) + { + Assert(doc.RootElement.TryGetProperty("data", out var data), $"Fixture '{fixture.Name}' expected data field."); + if (!string.IsNullOrWhiteSpace(fixture.DataKind)) + { + var expectedKind = ParseValueKind(fixture.DataKind!); + Assert(data.ValueKind == expectedKind, $"Fixture '{fixture.Name}' expected data kind {expectedKind} but got {data.ValueKind}."); + } + continue; + } + + Assert(doc.RootElement.TryGetProperty("error", out var error), $"Fixture '{fixture.Name}' expected error field."); + if (!string.IsNullOrWhiteSpace(fixture.ErrorContains)) + { + var message = error.GetString() ?? ""; + Assert(message.Contains(fixture.ErrorContains!, StringComparison.OrdinalIgnoreCase), $"Fixture '{fixture.Name}' expected error containing '{fixture.ErrorContains}'."); + } + } + } +} + +sealed class TransportFixture +{ + public string Name { get; init; } = ""; + public string Request { get; init; } = ""; + public bool ExpectOk { get; init; } + public string? DataKind { get; init; } + public string? ErrorContains { get; init; } +} + diff --git a/Journal.SmokeTests/Program.VaultTests.cs b/Journal.SmokeTests/Program.VaultTests.cs new file mode 100644 index 0000000..9455be4 --- /dev/null +++ b/Journal.SmokeTests/Program.VaultTests.cs @@ -0,0 +1,516 @@ +internal static partial class Program +{ + static Task TestVaultCryptoRoundtripAsync() + { + var crypto = new VaultCryptoService(); + var plaintext = "sample vault payload"; + var payload = crypto.EncryptData(System.Text.Encoding.UTF8.GetBytes(plaintext), "vault-pass-123"); + + Assert(payload.Length == VaultCryptoService.SaltSize + VaultCryptoService.NonceSize + VaultCryptoService.TagSize + plaintext.Length, "Vault payload length should match salt+nonce+tag+ciphertext layout."); + var decrypted = crypto.DecryptData(payload, "vault-pass-123"); + Assert(System.Text.Encoding.UTF8.GetString(decrypted) == plaintext, "Vault roundtrip decrypt should return original plaintext."); + + return Task.CompletedTask; + } + + static Task TestVaultCryptoDecryptsPythonFixtureAsync() + { + var crypto = new VaultCryptoService(); + + var payload = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODwABAgMEBQYHCAkKC6AErhDEMERBl7OFkG4L4oZ2JZckS0VzhxaZoVLckF7VXE+NIYXILsJ8f1I="); + var expectedPlaintext = Convert.FromBase64String("dmF1bHQgcGF5bG9hZCBleGFtcGxlCmxpbmUy"); + var decrypted = crypto.DecryptData(payload, "vault-pass-123"); + + Assert(decrypted.SequenceEqual(expectedPlaintext), "C# decrypt should match Python-generated payload plaintext."); + + return Task.CompletedTask; + } + + static Task TestVaultKeyDerivationMatchesPythonAsync() + { + var crypto = new VaultCryptoService(); + var salt = Enumerable.Range(0, VaultCryptoService.SaltSize).Select(i => (byte)i).ToArray(); + var key = crypto.DeriveKey("vault-pass-123", salt); + var expectedKeyHex = "b29f523f28bf178f6815c6ca9ee2a588d79b3bd9a822c92a2f0dde5bc853bb52"; + var actualKeyHex = Convert.ToHexString(key).ToLowerInvariant(); + + Assert(actualKeyHex == expectedKeyHex, "Derived key should match Python PBKDF2 fixture key."); + + return Task.CompletedTask; + } + + static Task TestVaultMonthlyFilenameParityAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + try + { + var dbPath = dbService.GetDatabasePath(); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + File.WriteAllText(dbPath, "db-bytes"); + + storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!); + + var expectedName = $"_db_{Path.GetFileName(dbPath)}.vault"; + Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, expectedName)), "Expected DB vault snapshot filename with _db_ prefix and .vault suffix."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultLoadClearsAndExtractsAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + try + { + var dbPath = dbService.GetDatabasePath(); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + var originalBytes = Guid.NewGuid().ToByteArray(); + File.WriteAllBytes(dbPath, originalBytes); + + storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!); + + File.WriteAllBytes(dbPath, [1, 2, 3, 4]); + var ok = storage.LoadAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!); + + Assert(ok, "Expected vault load success with correct password."); + var loaded = File.ReadAllBytes(dbPath); + Assert(loaded.SequenceEqual(originalBytes), "Expected DB file bytes restored from vault snapshot."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultLoadWrongPasswordPreservesVaultAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + try + { + var dbPath = dbService.GetDatabasePath(); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + File.WriteAllText(dbPath, "db payload"); + + storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!); + var vaultPath = Path.Combine(config.Current.VaultDirectory, $"_db_{Path.GetFileName(dbPath)}.vault"); + var before = File.ReadAllBytes(vaultPath); + + var ok = storage.LoadAllVaults("wrong-password", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!); + var after = File.ReadAllBytes(vaultPath); + + Assert(!ok, "Expected vault load failure with wrong password."); + Assert(before.SequenceEqual(after), "Vault file bytes should remain unchanged on wrong password."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultLoadLegacyInitVaultHandlingAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + try + { + var legacyPath = Path.Combine(config.Current.VaultDirectory, "_init_vault.vault"); + File.WriteAllBytes(legacyPath, [1, 2, 3, 4]); + + var ok = storage.LoadAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbService.GetDatabasePath())!); + + Assert(ok, "Legacy-only vault directory should still be treated as successful load state."); + Assert(File.Exists(legacyPath), "Legacy _init_vault.vault should be ignored in SQLCipher snapshot mode."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultCurrentMonthSaveOptimizedAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + try + { + var dbPath = dbService.GetDatabasePath(); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + File.WriteAllText(dbPath, "initial db"); + + storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!); + var vaultPath = Path.Combine(config.Current.VaultDirectory, $"_db_{Path.GetFileName(dbPath)}.vault"); + Assert(File.Exists(vaultPath), "Expected DB vault snapshot file to be created."); + + var firstLength = new FileInfo(vaultPath).Length; + storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!); + var secondLength = new FileInfo(vaultPath).Length; + + Assert(firstLength > 0 && secondLength > 0, "Vault snapshot should remain non-empty across repeated rebuilds."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultRebuildAllVaultsAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + try + { + var dbDir = Path.GetDirectoryName(dbService.GetDatabasePath())!; + Directory.CreateDirectory(dbDir); + File.WriteAllText(Path.Combine(dbDir, "journal_cache.db"), "primary"); + File.WriteAllText(Path.Combine(dbDir, "analytics.db"), "secondary"); + + storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir); + + Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, "_db_journal_cache.db.vault")), "Expected primary DB snapshot vault."); + Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, "_db_analytics.db.vault")), "Expected secondary DB snapshot vault."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultClearDataDirectoryAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + var scratchDir = Path.Combine(root, "scratch-data"); + Directory.CreateDirectory(scratchDir); + Directory.CreateDirectory(Path.Combine(scratchDir, "nested")); + + try + { + File.WriteAllText(Path.Combine(scratchDir, "tmp.md"), "decrypted content"); + File.WriteAllText(Path.Combine(scratchDir, "nested", "tmp.txt"), "temp"); + + storage.ClearDataDirectory(scratchDir); + + Assert(!Directory.Exists(scratchDir), "Non-db scratch directory should be deleted by clear_data_directory."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static async Task TestEntryVaultLoadAllEmptyAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var entry = NewLockedEntry(); + var request = JsonSerializer.Serialize(new + { + action = "vault.load_all", + payload = new + { + password = "vault-pass-123", + vaultDirectory = Path.Combine(root, "vault") + } + }); + + Directory.CreateDirectory(Path.Combine(root, "vault")); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for empty vault directory load."); + Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault directory."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task TestEntryVaultClearDataDirectoryAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "vault.clear_data_directory", + payload = new { } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for clear_data_directory."); + Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected clear_data_directory result=true."); + } + + static Task TestSidecarVaultCliLoadAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + + using var session = new DatabaseSessionService(dbService); + session.SetPassword("vault-pass-123"); + + var repo = new SqliteEntryFileRepository(session); + var entryFiles = new EntryFileService(repo); + var searchService = new EntrySearchService(repo); + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles); + + var vaultDir = Path.Combine(root, "vault-cli"); + Directory.CreateDirectory(vaultDir); + + try + { + var exitCode = cli.RunVaultCommand(["load", "--password", "vault-pass-123", "--vault-dir", vaultDir]); + Assert(exitCode == 0, "Expected vault load CLI command to succeed on empty vault directory."); + + var dbDir = Path.Combine(config.Current.VaultDirectory, "db"); + Assert(Directory.Exists(dbDir), "Expected db directory to be created by vault load CLI command."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestSidecarVaultCliSaveAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + + using var session = new DatabaseSessionService(dbService); + session.SetPassword("vault-pass-123"); + + var repo = new SqliteEntryFileRepository(session); + var entryFiles = new EntryFileService(repo); + var searchService = new EntrySearchService(repo); + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles); + + var vaultDir = Path.Combine(root, "vault-cli"); + Directory.CreateDirectory(vaultDir); + + try + { + entryFiles.SaveEntry(new EntrySavePayload("entry body", Mode: "Overwrite", FileName: "2026-02-22")); + session.CloseConnection(); + + var exitCode = cli.RunVaultCommand(["save", "--password", "vault-pass-123", "--vault-dir", vaultDir]); + + Assert(exitCode == 0, "Expected vault save CLI command to succeed."); + Assert(File.Exists(Path.Combine(vaultDir, "_db_journal_cache.db.vault")), "Expected DB vault snapshot file to be written by save CLI command."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultCustomEntryRoundtripAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var storage = new VaultStorageService(new VaultCryptoService(), dbService); + + try + { + using (var seedSession = new DatabaseSessionService(dbService)) + { + seedSession.SetPassword("vault-pass-123"); + var seedRepo = new SqliteEntryFileRepository(seedSession); + var seedEntryFiles = new EntryFileService(seedRepo); + + seedEntryFiles.SaveEntry(new EntrySavePayload("date entry", Mode: "Overwrite", FileName: "2026-02-01")); + seedEntryFiles.SaveEntry(new EntrySavePayload("custom entry body", Mode: "Overwrite", FileName: "My Custom Entry")); + seedEntryFiles.SaveEntry(new EntrySavePayload("work notes body", Mode: "Overwrite", FileName: "Work Notes")); + seedSession.CloseConnection(); + } + + var dbPath = dbService.GetDatabasePath(); + var dbDir = Path.GetDirectoryName(dbPath)!; + + storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir); + Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, "_db_journal_cache.db.vault")), "Expected DB vault snapshot to be created."); + + File.Delete(dbPath); + Assert(!File.Exists(dbPath), "DB file should be deleted before restore."); + + var ok = storage.LoadAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir); + Assert(ok, "Expected vault load to succeed."); + + using (var verifySession = new DatabaseSessionService(dbService)) + { + verifySession.SetPassword("vault-pass-123"); + var verifyRepo = new SqliteEntryFileRepository(verifySession); + var verifyEntryFiles = new EntryFileService(verifyRepo); + var allEntries = verifyEntryFiles.ListEntries(); + Assert(allEntries.Any(e => e.FileName == "2026-02-01.md"), "Date entry should be restored from vault DB snapshot."); + Assert(allEntries.Any(e => e.FileName == "My Custom Entry.md"), "Custom entry should be restored from vault DB snapshot."); + Assert(allEntries.Any(e => e.FileName == "Work Notes.md"), "Second custom entry should be restored from vault DB snapshot."); + } + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static async Task TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var config = NewConfigService(root); + var dbService = new JournalDatabaseService(config); + var vaultStorage = new VaultStorageService(new VaultCryptoService(), dbService); + var session = new DatabaseSessionService(dbService); + var repo = new SqliteEntryFileRepository(session); + var entryFiles = new EntryFileService(repo); + var entrySearch = new EntrySearchService(repo); + + var entry = new Entry( + NewService(), + entrySearch, + vaultStorage, + dbService, + session, + config, + new DisabledAiService("none"), + new DisabledSpeechBridgeService("none"), + new DisabledS2TService(), + entryFiles, + new ListService(new SqliteListRepository(session)), + new TodoService(new SqliteTodoRepository(session)), + new DisabledCoachService(), + new ConversationService(new SqliteConversationRepository(session)), + new CommandLogger()); + + try + { + var dbDir = Path.GetDirectoryName(dbService.GetDatabasePath())!; + Directory.CreateDirectory(dbDir); + File.WriteAllBytes(dbService.GetDatabasePath(), Guid.NewGuid().ToByteArray()); + vaultStorage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir); + + var loadRequest = JsonSerializer.Serialize(new + { + action = "vault.load_all", + payload = new + { + password = "wrong-password", + vaultDirectory = config.Current.VaultDirectory + } + }); + var loadResponse = await entry.HandleCommandAsync(loadRequest); + using var loadDoc = JsonDocument.Parse(loadResponse); + Assert(loadDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.load_all response envelope to be ok=true."); + Assert(!loadDoc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=false with wrong password."); + + var listRequest = JsonSerializer.Serialize(new + { + action = "lists.list" + }); + var listResponse = await entry.HandleCommandAsync(listRequest); + using var listDoc = JsonDocument.Parse(listResponse); + Assert(!listDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected lists.list to fail while locked."); + var error = listDoc.RootElement.GetProperty("error").GetString() ?? ""; + Assert(error.Contains("database is locked", StringComparison.OrdinalIgnoreCase), "Expected locked-session error after failed vault.load_all."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task TestEntryTemplateSaveAutoSyncsVaultAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-entry-vault-sync-smoke", Guid.NewGuid().ToString("N")); + var entry = NewEntry(root: root); + + var configResponse = await entry.HandleCommandAsync("""{"action":"config.get"}"""); + using var configDoc = JsonDocument.Parse(configResponse); + var configData = configDoc.RootElement.GetProperty("data"); + var vaultDir = configData.GetProperty("VaultDirectory").GetString() ?? ""; + var dbFilename = configData.GetProperty("DatabaseFilename").GetString() ?? "journal_cache.db"; + + var saveTemplateRequest = JsonSerializer.Serialize(new + { + action = "templates.save", + payload = new + { + name = "Weekly Review", + content = "## Wins\n- shipped feature" + } + }); + var saveTemplateResponse = await entry.HandleCommandAsync(saveTemplateRequest); + using var saveTemplateDoc = JsonDocument.Parse(saveTemplateResponse); + Assert(saveTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected templates.save to succeed."); + + var dbVaultPath = Path.Combine(vaultDir, $"_db_{dbFilename}.vault"); + Assert(File.Exists(dbVaultPath), "Expected template save auto-sync to write DB vault snapshot."); + + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} diff --git a/Journal.SmokeTests/Program.cs b/Journal.SmokeTests/Program.cs new file mode 100644 index 0000000..3640da5 --- /dev/null +++ b/Journal.SmokeTests/Program.cs @@ -0,0 +1,98 @@ +internal static partial class Program +{ + private static async Task<int> Main() + { + var tests = new List<(string Name, Func<Task> Run)> + { + ("CreateAsync trims fields", TestCreateTrimsAsync), + ("UpdateAsync accepts valid type updates", TestUpdateAcceptsTypeAsync), + ("UpdateAsync rejects whitespace type", TestUpdateRejectsWhitespaceTypeAsync), + ("JournalEntry model stores parity fields", TestJournalEntryModelAsync), + ("MergeWith overwrites section when new content is meaningful", TestMergeOverwritesMeaningfulSectionAsync), + ("MergeWith ignores whitespace-only section updates", TestMergeIgnoresWhitespaceOnlySectionAsync), + ("MergeWith appends non-duplicate fragments by description", TestMergeAppendsNonDuplicateFragmentsAsync), + ("ToMarkdown writes canonical section order", TestToMarkdownCanonicalSectionOrderAsync), + ("ToMarkdown writes fragment blocks", TestToMarkdownFragmentFormattingAsync), + ("Vault crypto roundtrip preserves data and layout", TestVaultCryptoRoundtripAsync), + ("Vault crypto decrypts Python payload fixture", TestVaultCryptoDecryptsPythonFixtureAsync), + ("Vault key derivation matches Python fixture", TestVaultKeyDerivationMatchesPythonAsync), + ("Vault monthly filename matches parity format", TestVaultMonthlyFilenameParityAsync), + ("Vault load restores encrypted DB snapshot", TestVaultLoadClearsAndExtractsAsync), + ("Vault load wrong password does not modify vault files", TestVaultLoadWrongPasswordPreservesVaultAsync), + ("Vault load ignores legacy _init_vault.vault in snapshot mode", TestVaultLoadLegacyInitVaultHandlingAsync), + ("Vault rebuild remains repeatable across runs", TestVaultCurrentMonthSaveOptimizedAsync), + ("Vault rebuild writes encrypted DB snapshot vault files", TestVaultRebuildAllVaultsAsync), + ("Vault clear data directory removes decrypted workspace artifacts", TestVaultClearDataDirectoryAsync), + ("Parser extracts date from **Date:** marker", TestParserExtractsBoldDateAsync), + ("Parser extracts date from Date: marker", TestParserExtractsPlainDateAsync), + ("Parser falls back to file stem when date missing", TestParserFallsBackToFileStemAsync), + ("Parser captures canonical sections and content", TestParserCapturesSectionsAsync), + ("Parser ignores non-canonical section headers", TestParserIgnoresNonCanonicalHeadersAsync), + ("Parser captures checkbox states per section", TestParserCapturesCheckboxStatesAsync), + ("Parser captures multiline fragment blocks", TestParserCapturesMultilineFragmentsAsync), + ("Parser fragment boundary follows header lines", TestParserFragmentBoundaryBehaviorAsync), + ("File repository persists fragments", TestFileRepositoryPersistsAsync), + ("Entry invalid JSON returns error envelope", TestEntryInvalidJsonAsync), + ("Entry unknown action returns error envelope", TestEntryUnknownActionAsync), + ("Entry get missing id returns ok with null data", TestEntryGetMissingReturnsNullDataAsync), + ("Entry create without payload returns error envelope", TestEntryCreateMissingPayloadAsync), + ("Entry entries.save writes and merges content", TestEntryEntriesSaveMergeAsync), + ("Entry entries.load returns raw content payload", TestEntryEntriesLoadAsync), + ("Entry entries.list returns markdown files", TestEntryEntriesListAsync), + ("Entry templates CRUD stores .template.md and entries.list excludes templates", TestEntryTemplatesCrudExcludesFromEntriesListAsync), + ("Entry search.entries matches query against full raw content", TestEntrySearchEntriesMatchesRawContentAsync), + ("Entry search.entries without query returns all markdown entries", TestEntrySearchEntriesWithoutQueryReturnsAllAsync), + ("Entry search.entries applies date range filter", TestEntrySearchEntriesDateRangeFilterAsync), + ("Entry search.entries applies section-scoped query filter", TestEntrySearchEntriesSectionFilterAsync), + ("Entry search.entries applies fragment tag and type filters", TestEntrySearchEntriesTagTypeFilterAsync), + ("Entry search.entries applies checkbox checked and unchecked filters", TestEntrySearchEntriesCheckboxFilterAsync), + ("Entry search.entries rejects invalid date filter format", TestEntrySearchEntriesRejectsInvalidDateAsync), + ("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync), + ("Database schema parity tables are created", TestDatabaseSchemaParityAsync), + ("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync), + ("Entry db.initialize_schema initializes SQLCipher schema", TestEntryDatabaseInitializeSchemaAsync), + ("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync), + ("Config service exposes parity path, vault, AI, and speech settings", TestConfigServiceParityKeysAsync), + ("Entry config.get returns config payload", TestEntryConfigGetAsync), + ("Log redactor scrubs sensitive payload fields", TestLogRedactorScrubsSensitiveFieldsAsync), + ("Log redactor preserves non-sensitive payload fields", TestLogRedactorPreservesNonSensitiveFieldsAsync), + ("Entry ai.health returns disabled by default", TestEntryAiHealthDefaultAsync), + ("Entry ai.summarize_entry succeeds when disabled", TestEntryAiSummarizeEntryDisabledAsync), + ("Entry ai.summarize_all succeeds when disabled", TestEntryAiSummarizeAllDisabledAsync), + ("Entry ai.chat succeeds when disabled", TestEntryAiChatDisabledAsync), + ("Entry ai.embed returns empty vector when disabled", TestEntryAiEmbedDisabledAsync), + ("Entry speech.devices.list returns envelope when disabled", TestEntrySpeechDevicesListDisabledAsync), + ("Entry speech.transcribe returns envelope when disabled", TestEntrySpeechTranscribeDisabledAsync), + ("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync), + ("Entry vault.load_all wrong password keeps database session locked", TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync), + ("Entry vault.clear_data_directory compatibility command succeeds", TestEntryVaultClearDataDirectoryAsync), + ("Entry templates.save auto-syncs vault and survives reload", TestEntryTemplateSaveAutoSyncsVaultAsync), + ("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync), + ("Sidecar vault CLI save writes DB snapshot vault with --password", TestSidecarVaultCliSaveAsync), + ("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync), + ("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync), + ("Transport fixtures produce stable envelopes", TestTransportFixturesAsync), + ("EntrySavePayload deserializes camelCase fileName from JsonElement", TestEntrySavePayloadFileNameDeserializationAsync), + ("entries.save with fileName creates custom-named file", TestEntrySaveWithFileNameAsync), + ("Vault rebuild and load preserves custom-named entries", TestVaultCustomEntryRoundtripAsync), + }; + + var passed = 0; + foreach (var (name, run) in tests) + { + try + { + await run(); + Console.WriteLine($"PASS {name}"); + passed++; + } + catch (Exception ex) + { + Console.WriteLine($"FAIL {name}: {ex.Message}"); + } + } + + Console.WriteLine($"Summary: {passed}/{tests.Count} passed."); + return passed == tests.Count ? 0 : 1; + } +} diff --git a/Journal.WebGateway/Journal.WebGateway.csproj b/Journal.WebGateway/Journal.WebGateway.csproj new file mode 100644 index 0000000..031497c --- /dev/null +++ b/Journal.WebGateway/Journal.WebGateway.csproj @@ -0,0 +1,8 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\Journal.AI\Journal.AI.csproj" /> + <ProjectReference Include="..\Journal.Core\Journal.Core.csproj" /> + </ItemGroup> + +</Project> diff --git a/Journal.WebGateway/Program.cs b/Journal.WebGateway/Program.cs new file mode 100644 index 0000000..4500445 --- /dev/null +++ b/Journal.WebGateway/Program.cs @@ -0,0 +1,264 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Journal.AI; +using Journal.Core; +using Microsoft.Extensions.FileProviders; + +var gatewayJsonOptions = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +}; + +var repoRoot = ResolveRepoRoot(); +Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot); +var webDistPath = ResolveWebDist(repoRoot); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddFragmentServices(); +builder.Services.AddLlamaSharpServices(); +builder.Services.AddSingleton<Entry>(); +builder.Services.AddSingleton(new SidecarRootState(repoRoot)); +builder.Services.AddSingleton(new WebUiState(webDistPath)); + +builder.Services.AddCors(options => +{ + options.AddPolicy("GatewayCors", policy => + { + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.SerializerOptions.PropertyNameCaseInsensitive = true; +}); + +var app = builder.Build(); + +app.UseCors("GatewayCors"); + +app.MapGet("/api/health", () => Results.Ok(new +{ + ok = true, + service = "Journal.WebGateway" +})); + +app.MapGet("/api/web/status", (WebUiState webUiState) => Results.Ok(new +{ + distPath = webUiState.DistPath, + exists = webUiState.Exists +})); + +app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) => +{ + if (command is null || string.IsNullOrWhiteSpace(command.Action)) + { + return Results.Content(ErrorResponse("Missing action"), "application/json"); + } + + var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions); + var responseJson = await entry.HandleCommandAsync(inputJson); + return Results.Content(responseJson, "application/json"); +}); + +app.MapGet("/api/sidecar/root", (SidecarRootState rootState) => +{ + var (root, isCustom) = rootState.Get(); + return Results.Ok(new + { + root, + isCustom + }); +}); + +app.MapPost("/api/sidecar/root", (SetSidecarRootRequest? request, SidecarRootState rootState) => +{ + var path = request?.Path ?? ""; + if (!string.IsNullOrWhiteSpace(path) && !Directory.Exists(path)) + { + return Results.BadRequest($"Directory '{path}' does not exist."); + } + + rootState.Set(path); + var (root, isCustom) = rootState.Get(); + Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", root); + return Results.Ok(new + { + root, + isCustom + }); +}); + +if (Directory.Exists(webDistPath) && File.Exists(Path.Combine(webDistPath, "index.html"))) +{ + var fileProvider = new PhysicalFileProvider(webDistPath); + var indexPath = Path.Combine(webDistPath, "index.html"); + + app.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = fileProvider, + RequestPath = "" + }); + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = fileProvider, + RequestPath = "" + }); + + app.MapGet("/", async context => + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync(indexPath); + }); + + app.MapFallback(async context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync(indexPath); + }); +} +else +{ + app.MapGet("/", () => Results.Ok(new + { + name = "Journal.WebGateway", + status = "ok", + uiAvailable = false, + message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.", + expectedDist = webDistPath + })); +} + +app.Run(); + +string ResolveRepoRoot() +{ + var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + { + return Path.GetFullPath(fromEnv); + } + + foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + var resolved = FindRepoRoot(start); + if (resolved is not null) + { + return resolved; + } + } + + return Path.GetFullPath(Directory.GetCurrentDirectory()); +} + +string? FindRepoRoot(string start) +{ + var cursor = Path.GetFullPath(start); + + while (!string.IsNullOrWhiteSpace(cursor)) + { + if (File.Exists(Path.Combine(cursor, "Journal.slnx")) || + Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) || + Directory.Exists(Path.Combine(cursor, "Journal.Core"))) + { + return cursor; + } + + var parent = Directory.GetParent(cursor); + if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + cursor = parent.FullName; + } + + return null; +} + +string ResolveWebDist(string repoRootPath) +{ + var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST"); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + return Path.GetFullPath(fromEnv); + } + + var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + if (Directory.Exists(packagedWwwRoot)) + { + return packagedWwwRoot; + } + + return Path.Combine(repoRootPath, "Journal.App", "build"); +} + +string ErrorResponse(string message) + => JsonSerializer.Serialize(new { ok = false, error = message }, gatewayJsonOptions); + +sealed class WebUiState(string distPath) +{ + public string DistPath { get; } = distPath; + + public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html")); +} + +sealed class SidecarRootState(string autoRoot) +{ + private readonly object _sync = new(); + private readonly string _autoRoot = autoRoot; + private string _currentRoot = autoRoot; + private bool _isCustom; + + public (string Root, bool IsCustom) Get() + { + lock (_sync) + { + return (_currentRoot, _isCustom); + } + } + + public void Set(string? path) + { + lock (_sync) + { + if (string.IsNullOrWhiteSpace(path)) + { + _currentRoot = _autoRoot; + _isCustom = false; + return; + } + + _currentRoot = Path.GetFullPath(path.Trim()); + _isCustom = true; + } + } +} + +sealed class SetSidecarRootRequest +{ + public string? Path { get; set; } +} + +sealed class CommandEnvelope +{ + public string Action { get; set; } = ""; + public string? CorrelationId { get; set; } + public string? Id { get; set; } + public string? Type { get; set; } + public string? Tag { get; set; } + public JsonElement? Payload { get; set; } +} diff --git a/Journal.slnx b/Journal.slnx new file mode 100644 index 0000000..5e5cfd3 --- /dev/null +++ b/Journal.slnx @@ -0,0 +1,7 @@ +<Solution> + <Project Path="Journal.AI/Journal.AI.csproj" /> + <Project Path="Journal.Core/Journal.Core.csproj" /> + <Project Path="Journal.Sidecar/Journal.Sidecar.csproj" /> + <Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" /> + <Project Path="Journal.WebGateway/Journal.WebGateway.csproj" /> +</Solution> diff --git a/README.md b/README.md new file mode 100644 index 0000000..5948489 --- /dev/null +++ b/README.md @@ -0,0 +1,548 @@ +# Project Journal + +A structured journaling system with encrypted monthly vaults, a Tauri desktop app, a web gateway server, CLI tools, and optional AI-assisted analysis. + +## Repository Layout + +``` +backend/ Monorepo root +├── Journal.Core/ .NET class library — all business logic and services +├── Journal.AI/ .NET class library — LLM/AI integration (LLamaSharp) +├── Journal.Sidecar/ Console app — stdin/stdout JSON protocol (Tauri sidecar bridge + CLI) +├── Journal.WebGateway/ ASP.NET Core app — HTTP wrapper for browser/web mode +├── Journal.SmokeTests/ Integration tests (~80 tests, no test framework dependency) +├── Journal.App/ SvelteKit + Tauri desktop app +│ ├── src/ SvelteKit frontend source +│ ├── src-tauri/ Rust Tauri shell (sidecar process manager) +│ └── static/ Static assets +├── Journal.DevTool/ Pre-built SDT orchestrator (sdt.exe) + Python scripts +├── Directory.Build.props Shared .NET build properties (TFM, nullable, etc.) +├── Directory.Packages.props Centralized NuGet package versions +├── Journal.slnx Visual Studio solution (all .NET projects) +├── package.json npm workspace root (Journal.App) +└── devtool.json SDT workflow/toolchain configuration +``` + +## Deployment Modes + +The backend can run in three modes depending on the surface wired to it: + +| Mode | Host | Frontend | +|------|------|----------| +| **Tauri desktop app** | `Journal.App` (Tauri + Rust) | SvelteKit embedded via Tauri WebView | +| **WebGateway server** | `Journal.WebGateway` (ASP.NET Core) | SvelteKit build served from `wwwroot` | +| **Sidecar CLI / stdin** | `Journal.Sidecar` (console) | None — raw JSON protocol | + +All three modes share the same `Journal.Core` service layer and command protocol. + +--- + +## Platform Support + +- **Windows** — first-class (primary development target) +- **Linux** — first-class +- **macOS** — best effort + +### Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Node.js](https://nodejs.org/) + npm (for `Journal.App` frontend) +- [Rust + Cargo](https://rustup.rs/) (for `Journal.Sidecar` Tauri desktop build) +- PowerShell 7+ (`pwsh`) recommended for scripts + +--- + +## Quickstart + +### Option A — Tauri Desktop App + +Install dependencies and build: + +```powershell +npm install # from repo root (workspaces) +dotnet publish Journal.Sidecar/Journal.Sidecar.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true +npm run tauri build -w Journal.App +``` + +Tauri auto-detects `Journal.Sidecar.exe` in the repository. On first launch it walks up from the working directory to find `Journal.Sidecar/` and resolves the built executable. + +### Option B — WebGateway Server (browser mode) + +Build the web UI bundle, then publish the gateway with web assets embedded: + +```powershell +npm run build -w Journal.App +dotnet publish Journal.WebGateway/Journal.WebGateway.csproj -c Release -r win-x64 +``` + +Run the gateway: + +```powershell +dotnet run --project Journal.WebGateway +``` + +Open `http://localhost:5180` in your browser. The gateway automatically serves the SvelteKit build and proxies all `/api/command` calls to `Journal.Core`. + +Quick health check: + +```powershell +Invoke-RestMethod http://127.0.0.1:5180/api/health +``` + +### Option C — Sidecar / CLI only + +```powershell +dotnet build + +# Run in stdin/stdout protocol mode +dotnet run --project Journal.Sidecar + +# CLI subcommands +dotnet run --project Journal.Sidecar -- vault load --password <value> +dotnet run --project Journal.Sidecar -- vault save --password <value> +dotnet run --project Journal.Sidecar -- search "your query" --tag stress --start-date 2026-02-01 +``` + +--- + +## C# Backend + +### Projects + +| Project | Type | Purpose | +|---------|------|---------| +| `Journal.Core` | Class library | Domain models, services, repositories, DTOs — all business logic | +| `Journal.AI` | Class library | LLM/AI integration (LLamaSharp) — references Journal.Core | +| `Journal.Sidecar` | Console app | Stdin/stdout JSON protocol + vault/search CLI subcommands | +| `Journal.WebGateway` | ASP.NET Core | HTTP API wrapper; serves built SvelteKit UI from `wwwroot` | +| `Journal.SmokeTests` | Console app | ~80 integration tests (no xunit/nunit) | + +### Solution File + +``` +Journal.slnx (all .NET projects: Core, AI, Sidecar, WebGateway, SmokeTests) +``` + +All .NET projects share build properties via `Directory.Build.props` and NuGet versions via `Directory.Packages.props` (central package management). + +### Architecture + +``` +Entry (thin command dispatcher — shared by all three hosts) + ├── Fragments/ IFragmentService → FragmentService → SQLiteFragmentRepository (SQLCipher) + ├── Entries/ IEntryFileService → EntryFileService → SqliteEntryFileRepository + │ IEntrySearchService → EntrySearchService (raw content + structured filters) + │ JournalParser (date / section / checkbox / fragment parsing) + ├── Lists/ IListService → ListService → SqliteListRepository + ├── Todos/ ITodoService → TodoService → SqliteTodoRepository + ├── Vault/ IVaultStorageService → VaultStorageService → IVaultCryptoService + ├── Database/ IJournalDatabaseService (SQLCipher schema/key derivation/hydration) + │ IDatabaseSessionService (encrypted connection lifecycle after auth) + ├── Ai/ IAiService → LlamaSharpAiService | DisabledAiService + ├── Speech/ ISpeechBridgeService → DisabledSpeechBridgeService + ├── Sidecar/ SidecarCli + ├── Logging/ CommandLogger, LogRedactor + └── Config/ IJournalConfigService → JournalConfigService +``` + +Services live under `Journal.Core/Services/` in domain-specific subdirectories, each with its own namespace (e.g. `Journal.Core.Services.Ai`). + +### Build + +```powershell +# Build all .NET projects via the solution +dotnet build +``` + +### Run Smoke Tests + +```powershell +dotnet run --project Journal.SmokeTests +``` + +### Dependencies + +NuGet package versions are managed centrally in `Directory.Packages.props`. Project-level `.csproj` files reference packages without version numbers. + +- `Journal.Core` — `Microsoft.Data.Sqlite.Core`, `SQLitePCLRaw.bundle_e_sqlcipher`, `Microsoft.Extensions.DependencyInjection.Abstractions` +- `Journal.AI` — `LLamaSharp`, `LLamaSharp.Backend.Cpu` + references `Journal.Core` +- `Journal.Sidecar` — `Microsoft.Extensions.DependencyInjection`, `NAudio`, `Whisper.net` + references `Journal.Core`, `Journal.AI` +- `Journal.WebGateway` — `Microsoft.NET.Sdk.Web` + references `Journal.Core`, `Journal.AI` +- `Journal.SmokeTests` — references `Journal.Core` + +### Encryption + +- **Vault**: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iterations) +- **Database**: SQLCipher with PBKDF2-derived key +- Fragments and structured data are stored in the encrypted SQLCipher database; auth is required via `vault.load_all` or `db.hydrate_workspace` +- `DatabaseSessionService` holds the encryption password in memory after first auth and closes the connection on `vault.clear_data_directory` + +### Environment Variables + +**Journal backend:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `JOURNAL_PROJECT_ROOT` | auto-detected | Override project root path (vault path resolution) | +| `JOURNAL_VAULT_DIR` | `<root>/journal/vault` | Override vault directory path | +| `JOURNAL_DATA_DIR` | _(empty)_ | Override decrypted data directory path | +| `JOURNAL_AI_PROVIDER` | `none` | AI provider mode (`none`, `llamasharp`) | +| `JOURNAL_LOG_LEVEL` | `warning` | Log verbosity (`trace`, `debug`, `information`, `warning`, `error`, `critical`) | +| `JOURNAL_WEB_DIST` | auto | Override web UI dist path for WebGateway | + +**SDT orchestrator:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `SDT_ENV_PROFILE` | `dev` | Active runtime environment profile (`dev`, `ci`, `release`) | +| `SDT_LOG_LEVEL` | `information` | CLI log verbosity (`trace` through `critical`) | + +--- + +## Journal.WebGateway + +An ASP.NET Core minimal API that wraps `Journal.Core` for browser use. + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/health` | Health check | +| `POST` | `/api/command` | Send a JSON command to `Entry.HandleCommandAsync` | +| `GET` | `/api/web/status` | Reports web dist path and whether UI is available | +| `GET` | `/api/sidecar/root` | Returns current project root (auto-detected or custom) | +| `POST` | `/api/sidecar/root` | Override project root at runtime | +| `GET` | `/*` | Serves built SvelteKit UI from `wwwroot` (SPA fallback) | + +### Web UI Resolution + +On startup, `Journal.WebGateway` resolves the web dist in this order: + +1. `JOURNAL_WEB_DIST` environment variable +2. `<AppContext.BaseDirectory>/wwwroot` (embedded in published output) +3. `Journal.App/build` (dev fallback — relative to repo root) + +If no dist is found, `/` returns a JSON status message instead of the UI. + +### Running WebGateway + +```powershell +dotnet run --project Journal.WebGateway +``` + +--- + +## Journal.App (Tauri + SvelteKit) + +A Tauri 2 desktop application with a SvelteKit 5 / TypeScript frontend. + +### Tech Stack + +- **Frontend**: SvelteKit 5, TypeScript, Vite 6 +- **Tauri shell**: Rust (Tauri 2), `tokio` for async process I/O +- **Backend bridge**: `Journal.Sidecar.exe` managed as a long-lived child process + +### Tauri Sidecar Architecture + +The Rust layer (`src-tauri/src/lib.rs`) manages a persistent `Journal.Sidecar.exe` child process: + +- Sidecar is auto-started on first command and restarted if it dies +- Commands are sent as JSON lines to stdin, responses read from stdout +- `JOURNAL_PROJECT_ROOT` is set to the resolved repo root before spawning +- On Windows, the process is created with `CREATE_NO_WINDOW` + +Tauri commands exposed to the frontend: + +| Command | Description | +|---------|-------------| +| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` and return parsed JSON | +| `get_sidecar_root` | Get the current resolved sidecar root path | +| `set_sidecar_root` | Override sidecar root path (saves to `settings.json`, restarts sidecar) | +| `get_ui_settings` | Load tag/fragment-type settings from `settings.json` | +| `set_ui_settings` | Persist tag/fragment-type settings | +| `shutdown` | Stop the sidecar and exit the app | + +Sidecar path resolution order: + +1. Exact sidecar binary path if the configured root is already the executable +2. `<root>/Journal.Sidecar(.exe)` +3. Recursive scan of `<root>/Journal.Sidecar/` +4. Tauri bundled resource path: `<resourceDir>/bin/Journal.Sidecar(.exe)` + +### Frontend State + +The frontend uses Svelte stores as the source of truth: + +| Store | State | Purpose | +|-------|-------|---------| +| `entries.ts` | `entriesStore` | Journal entry list and drafts | +| `fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers | +| `todos.ts` | `todoListsStore`, `todosStore` | Todo lists and items | +| `lists.ts` | `listsStore` | Generic lists | +| `settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type configuration | + +**Store-First Rule**: components call store helpers for CRUD; they do not embed mutation or parsing logic directly. + +### Dev Setup + +```powershell +npm install # from repo root (workspaces) +npm run dev -w Journal.App # SvelteKit dev server at http://localhost:1420 +npm run tauri dev -w Journal.App # Tauri dev mode (opens desktop window) +``` + +### Publishing + +```powershell +# Web bundle only (for WebGateway) +npm run build -w Journal.App +# Output: Journal.App/build/ + +# Tauri raw exe (no installer) +npm run tauri build -w Journal.App -- -- --bundles none +# Output: Journal.App/src-tauri/target/release/journalapp.exe + +# Tauri with NSIS installer +npm run tauri build -w Journal.App -- -- --bundles nsis +``` + +--- + +## Sidecar Protocol + +`Journal.Sidecar` communicates over **stdin/stdout** using newline-delimited JSON. One JSON object in, one JSON object out. + +### Command Format + +```json +{ + "action": "fragments.create", + "correlationId": null, + "id": null, + "type": null, + "tag": null, + "payload": { "type": "!TRIGGER", "description": "stomach drop" } +} +``` + +**Fields:** +- `action` — Operation to perform (e.g. `fragments.list`, `vault.load_all`) +- `correlationId` — Optional tracing ID (auto-generated if omitted) +- `id` — Target entity ID (for get/update/delete) +- `type` / `tag` — Filter parameters (for fragment search) +- `payload` — Request body, deserialized per action + +### Response Format + +Success: +```json +{ "ok": true, "data": { "id": "abc-123", "type": "!TRIGGER", "description": "...", "time": "...", "tags": [] } } +``` + +Error: +```json +{ "ok": false, "error": "Description is required" } +``` + +### Available Actions + +| Action | Description | Key Requirements | +|--------|-------------|------------------| +| `fragments.list` | List all fragments | — | +| `fragments.get` | Get by ID | `id` | +| `fragments.create` | Create fragment | `payload` (CreateFragmentDto) | +| `fragments.update` | Update fragment | `id`, `payload` (UpdateFragmentDto) | +| `fragments.delete` | Delete fragment | `id` | +| `fragments.search` | Filter by type/tag | `type` and/or `tag` | +| `lists.list` | List all lists | — | +| `lists.get` | Get list by ID | `id` | +| `lists.create` | Create list | `payload` | +| `lists.update` | Update list | `id`, `payload` | +| `lists.delete` | Delete list | `id` | +| `todos.list` | List all todo lists | — | +| `todos.get` | Get todo list by ID | `id` | +| `todos.create` | Create todo list | `payload` | +| `todos.update` | Update todo list | `id`, `payload` | +| `todos.delete` | Delete todo list | `id` | +| `todos.items.create` | Add todo item | `payload` | +| `todos.items.update` | Update todo item | `id`, `payload` | +| `todos.items.delete` | Delete todo item | `id` | +| `entries.list` | List persisted entries from SQLCipher store | — | +| `entries.load` | Load one entry file | `payload.filePath` | +| `entries.save` | Save/merge entry content | `payload.content`, optional `payload.filePath`, `payload.mode`, `payload.fileName` | +| `entries.delete` | Delete an entry file | `payload.filePath` | +| `templates.list` | List templates from SQLCipher store | — | +| `templates.load` | Load a template | `payload.filePath` | +| `templates.save` | Save/create a template | `payload.name` | +| `templates.delete` | Delete a template | `payload.filePath` | +| `search.entries` | Search entries with filters | optional query/section/date/tags/types/checked/unchecked | +| `vault.initialize` | Ensure vault directory exists | `payload.password`, `payload.vaultDirectory` | +| `vault.load_all` | Restore encrypted SQLCipher DB snapshot from vault | `payload.password`, `payload.vaultDirectory` | +| `vault.rebuild_all` | Persist encrypted SQLCipher DB snapshot to vault | `payload.password`, `payload.vaultDirectory` | +| `vault.clear_data_directory` | No-op for SQLCipher-first mode (compat command) | — | +| `db.status` | DB key/schema compatibility snapshot | `payload.password` | +| `db.initialize_schema` | Initialize SQLCipher schema in the database file | `payload.password` | +| `db.hydrate_workspace` | Bootstrap DB + set session password | `payload.password` | +| `config.get` | Return current config snapshot | — | +| `ai.health` | AI provider health status | — | +| `ai.summarize_entry` | Summarize one entry | `payload.content`, optional `payload.fileStem` | +| `ai.summarize_all` | Summarize multiple entries | `payload.entries[]` | +| `ai.chat` | Chat via AI provider bridge | `payload.prompt` | +| `ai.embed` | Generate embedding vector | `payload.content` | +| `speech.devices.list` | List audio input devices | — | +| `speech.transcribe` | Transcribe audio (base64) or text | `payload.audioBase64` or `payload.text` | + +### Sidecar CLI Mode + +In addition to stdin/stdout protocol, `Journal.Sidecar` supports direct CLI subcommands: + +```powershell +# Load/decrypt vault snapshot into SQLCipher DB workspace +dotnet run --project Journal.Sidecar -- vault load + +# Save (rebuild) vault snapshot from SQLCipher DB +dotnet run --project Journal.Sidecar -- vault save + +# Search entries (query + filters) +dotnet run --project Journal.Sidecar -- search "common text" --tag stress --type !TRIGGER --start-date 2026-02-01 --end-date 2026-02-28 --section Summary --checked "med taken" +``` + +**Password behavior:** +- Omit `--password` → prompts securely in terminal +- Pass `--password <value>` → non-interactive/automation mode + +**Optional path overrides:** +- `--vault-dir <path>` +- Env fallback: `JOURNAL_VAULT_DIR`, `JOURNAL_DATABASE_DIR`, `JOURNAL_APP_DIR` + +**Search CLI flags:** +- positional `query` (optional) +- `--tag` / `-t` (repeatable) +- `--type` / `-y` (repeatable) +- `--start-date` / `-s` (`yyyy-MM-dd`) +- `--end-date` / `-e` (`yyyy-MM-dd`) +- `--section` / `-sec` +- `--checked` / `-chk` (repeatable) +- `--unchecked` / `-uchk` (repeatable) +--- + +## Publishing + +### Sidecar (self-contained executable) + +```powershell +dotnet publish Journal.Sidecar/Journal.Sidecar.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true +# Output: Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe +``` + +To exclude debug symbols: add `-p:DebugType=none` + +For a smaller build that requires .NET 10 on the target machine: + +```powershell +dotnet publish Journal.Sidecar/Journal.Sidecar.csproj -c Release -r win-x64 -p:PublishSingleFile=true +``` + +### WebGateway (with embedded web UI) + +```powershell +# Step 1: build web assets +npm run build -w Journal.App + +# Step 2: publish gateway +dotnet publish Journal.WebGateway/Journal.WebGateway.csproj -c Release -r win-x64 +``` + +--- + +## DI Registration + +`ServiceCollectionExtensions.AddFragmentServices()` wires everything. Any host calls: + +```csharp +services.AddFragmentServices(); +services.AddSingleton<Entry>(); +``` + +Key registrations: +- `IDatabaseSessionService` → `DatabaseSessionService` (singleton) +- `IFragmentRepository` → `SqliteFragmentRepository` (singleton, SQLCipher-backed) +- `IFragmentService` → `FragmentService` (singleton) +- `IEntryFileRepository` → `SqliteEntryFileRepository` (singleton, SQLCipher-backed) +- `IEntryFileService` → `EntryFileService` (singleton) +- `IListRepository` → `SqliteListRepository` (singleton) +- `IListService` → `ListService` (singleton) +- `ITodoRepository` → `SqliteTodoRepository` (singleton) +- `ITodoService` → `TodoService` (singleton) +- `IVaultCryptoService` → `VaultCryptoService` (singleton) +- `IVaultStorageService` → `VaultStorageService` (singleton) +- `IJournalDatabaseService` → `JournalDatabaseService` (singleton) +- `IAiService` → `LlamaSharpAiService` or `DisabledAiService` (per `JOURNAL_AI_PROVIDER`) +- `ISpeechBridgeService` → `DisabledSpeechBridgeService` +- `IJournalConfigService` → `JournalConfigService` (singleton) +- `CommandLogger` (singleton) +- `SidecarCli` (singleton) + +--- + +## Extending with New Modules + +The `Command`/`Entry` pattern uses dot-notation actions. To add a module: + +1. Create model, DTO, repository, and service in `Journal.Core/Services/<Domain>/` +2. Register services in `ServiceCollectionExtensions.cs` +3. Inject the service into `Entry.cs` and add cases to the `switch` +4. No changes needed to `App.cs`, `Journal.WebGateway/Program.cs`, or the Tauri Rust shell + +--- + +## SDT DevTool + +`Journal.DevTool/` contains the pre-built SDT (Stan's Dev Tools) orchestrator (`sdt.exe`) and its Python build/automation scripts. Workflows are defined in `devtool.json` at the repo root. See [`Journal.DevTool/README.md`](Journal.DevTool/README.md) for full documentation. + +### Workflows (`devtool.json`) + +| ID | Label | Group | Description | +|----|-------|-------|-------------| +| `build-dotnet` | Build .NET Projects | Build | `dotnet build` — all C# projects in solution | +| `sidecar` | Publish Sidecar | Build | Build Journal.Sidecar as self-contained exe → output/ | +| `web` | Build Web UI | Build | Build SvelteKit bundle → Journal.App/build/ | +| `webgateway` | Publish WebGateway | Build | Publish ASP.NET host with embedded web UI (depends on `web`) | +| `tauri` | Build Tauri Desktop App | Build | Build desktop exe, no installer (depends on `sidecar`) | +| `tauri-nsis` | Build Tauri + NSIS Installer | Build | Build desktop exe with NSIS installer (depends on `sidecar`) | +| `all` | Full Release Build ✦ | Build | Sidecar → Web → WebGateway → Tauri, in dependency order | +| `sync-output` | Sync Build Assets to Output | Build | Sweep repo for newest builds and copy to output/ | +| `stage-output` | Stage Output Bundle | Build | Full publish + stage journalapp.exe into output/ | +| `run-gateway-dev` | Run WebGateway Server (Dev) | Dev | Start HTTP gateway via `dotnet run` at http://localhost:5180 | +| `run-gateway-prod` | Run WebGateway Server (Output) | Dev | Start compiled gateway from output/webgateway | +| `test` | Run Smoke Tests | Test | Run all ~80 integration tests in Journal.SmokeTests | +| `gate` | Run Migration Gate | Test | Full build + smoke tests + parity check | +| `nuget-export` | Export NuGet Cache | Cache | Prime and export .nuget cache to zip for offline use | +| `nuget-import` | Import NuGet Cache | Cache | Import cache zip and validate restore | +| `npm-clean` | Clean Node Modules | System | Remove Journal.App node_modules | + +### Environment Profiles + +SDT supports `dev`, `ci`, and `release` profiles (configured in `devtool.json` under `envProfiles`). Select the active profile via `SDT_ENV_PROFILE` or from the TUI. + +### Key Scripts (`Journal.DevTool/scripts/`) + +| Script | Purpose | +|--------|---------| +| `build.py` | Orchestrated project builds | +| `publish-sidecar.py` | Publish `Journal.Sidecar` single-file exe | +| `publish-app.py` | Build web bundle or Tauri desktop app | +| `publish-webgateway.py` | Publish `Journal.WebGateway` with web assets | +| `publish-output.py` | Stage full output bundle | +| `run-webgateway.py` | Run `Journal.WebGateway` with controlled env | +| `migration-gate.py` | End-to-end build + smoke + parity check gate | +| `pip-min.py` | `pip` wrapper with repo-local cache | +| `dotnet-min.py` | `dotnet` wrapper with resilient NuGet defaults | + +--- + +## Notes + +- Journal content and templates persist in SQLCipher (`entry_documents`) under the vault DB directory. +- The legacy placeholder file `_init_vault.vault` is treated as obsolete — the C# backend ignores and removes it during vault load. +- On Windows + Tauri, the sidecar process is spawned with `CREATE_NO_WINDOW` to suppress the console window. diff --git a/devtool.json b/devtool.json new file mode 100644 index 0000000..a3aa0a8 --- /dev/null +++ b/devtool.json @@ -0,0 +1,786 @@ +{ + "name": "Project Journal", + "version": "0.1.0", + "targets": [], + "workflows": [ + { + "id": "sidecar", + "label": "Publish Sidecar", + "description": "Build Journal.Sidecar as self-contained exe \u2192 output/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "sidecar:run", + "label": "Publish Sidecar", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-sidecar.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "web", + "label": "Build Web UI", + "description": "Build SvelteKit bundle \u2192 Journal.App/build/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "web:run", + "label": "Build Web UI", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "web" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "sync-output", + "label": "Sync Build Assets to Output", + "description": "Sweep repo for newest builds (Web/Sidecar/Gateway/Tauri) and copy them to the output dir", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "sync-output:run", + "label": "Sync Build Assets to Output", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/sync-output.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "webgateway", + "label": "Publish WebGateway", + "description": "Publish ASP.NET host with embedded web UI \u2192 output/webgateway/", + "group": "Build", + "dependsOn": [ + "web" + ], + "steps": [ + { + "id": "webgateway:run", + "label": "Publish WebGateway", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-webgateway.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "tauri", + "label": "Build Tauri Desktop App", + "description": "Build desktop exe (no installer) \u2192 Journal.App/src-tauri/target/release/", + "group": "Build", + "dependsOn": [ + "sidecar" + ], + "steps": [ + { + "id": "tauri:run", + "label": "Build Tauri Desktop App", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "none" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "tauri-nsis", + "label": "Build Tauri \u002B NSIS Installer", + "description": "Build desktop exe with NSIS installer package", + "group": "Build", + "dependsOn": [ + "sidecar" + ], + "steps": [ + { + "id": "tauri-nsis:run", + "label": "Build Tauri \u002B NSIS Installer", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "nsis" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "build-dotnet", + "label": "Build .NET Projects", + "description": "dotnet build \u2014 all C# projects in solution", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "build-dotnet:run", + "label": "Build .NET Projects", + "command": "dotnet", + "args": [ + "build" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "all", + "label": "Full Release Build \u2726", + "description": "Sidecar \u2192 Web \u2192 WebGateway \u2192 Tauri, in dependency order", + "group": "Build", + "dependsOn": [ + "sidecar", + "web", + "webgateway", + "tauri" + ], + "steps": [] + }, + { + "id": "run-gateway-dev", + "label": "Run WebGateway Server (Dev)", + "description": "Start HTTP gateway via \u0027dotnet run\u0027 at http://localhost:5180", + "group": "Dev", + "dependsOn": [], + "steps": [ + { + "id": "run-gateway-dev:run", + "label": "Run WebGateway Server (Dev)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/run-webgateway.ps1", + "-Mode", + "Dev" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "run-gateway-prod", + "label": "Run WebGateway Server (Output)", + "description": "Start compiled gateway from output/webgateway at http://localhost:5180", + "group": "Dev", + "dependsOn": [], + "steps": [ + { + "id": "run-gateway-prod:run", + "label": "Run WebGateway Server (Output)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/run-webgateway.ps1", + "-Mode", + "Output" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "test", + "label": "Run Smoke Tests", + "description": "Run all ~80 integration tests in Journal.SmokeTests", + "group": "Test", + "dependsOn": [], + "steps": [ + { + "id": "test:run", + "label": "Run Smoke Tests", + "command": "dotnet", + "args": [ + "run", + "--project", + "Journal.SmokeTests/Journal.SmokeTests.csproj" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "gate", + "label": "Run Migration Gate", + "description": "Full build \u002B smoke tests \u002B parity check", + "group": "Test", + "dependsOn": [], + "steps": [ + { + "id": "gate:run", + "label": "Run Migration Gate", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/migration-gate.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "nuget-export", + "label": "Export NuGet Cache", + "description": "Prime and export .nuget cache to zip for offline use", + "group": "Cache", + "dependsOn": [], + "steps": [ + { + "id": "nuget-export:run", + "label": "Export NuGet Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-export-cache.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "nuget-import", + "label": "Import NuGet Cache", + "description": "Import cache zip and validate restore", + "group": "Cache", + "dependsOn": [], + "steps": [ + { + "id": "nuget-import:run", + "label": "Import NuGet Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-import-cache.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "npm-clean", + "label": "Clean Node Modules", + "description": "Remove Journal.App node_modules (kills node/tauri first)", + "group": "System", + "dependsOn": [], + "steps": [ + { + "id": "npm-clean:run", + "label": "Clean Node Modules", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/npm-clean.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "stage-output", + "label": "Stage Output Bundle", + "description": "Publish sidecar \u002B web \u002B webgateway \u002B tauri, then stage journalapp.exe into output/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "stage-output:run", + "label": "Stage Output Bundle", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-output.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + } + ], + "env": [ + { + "key": "JOURNAL_AI_PROVIDER", + "description": "AI provider bridge mode", + "default": "none", + "options": [ + "none", + "python-sidecar" + ] + }, + { + "key": "JOURNAL_LOG_LEVEL", + "description": "Log verbosity for C# backend", + "default": "warning", + "options": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical" + ] + }, + { + "key": "JOURNAL_NLP_BACKEND", + "description": "Python NLP backend selection", + "default": "auto", + "options": [ + "auto", + "spacy", + "fallback" + ] + }, + { + "key": "JOURNAL_PROJECT_ROOT", + "description": "Override project root path (blank = auto-detect)", + "default": "", + "options": [] + }, + { + "key": "JOURNAL_VAULT_DIR", + "description": "Override vault directory path", + "default": "", + "options": [] + }, + { + "key": "JOURNAL_DATA_DIR", + "description": "Override decrypted data directory path", + "default": "", + "options": [] + }, + { + "key": "SDT_ENV_PROFILE", + "description": "Active SDT runtime environment profile", + "default": "dev", + "options": [ + "dev", + "ci", + "release" + ] + }, + { + "key": "SDT_LOG_LEVEL", + "description": "CLI log verbosity", + "default": "information", + "options": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical" + ] + } + ], + "envProfiles": { + "active": "dev", + "profiles": [ + { + "id": "dev", + "description": "Local development defaults", + "inherits": [], + "values": { + "SDT_ENV_PROFILE": "dev", + "SDT_LOG_LEVEL": "information" + } + }, + { + "id": "ci", + "description": "Continuous integration defaults", + "inherits": [ + "dev" + ], + "values": { + "SDT_ENV_PROFILE": "ci", + "CI": "false", + "SDT_LOG_LEVEL": "warning" + } + }, + { + "id": "release", + "description": "Release build defaults", + "inherits": [ + "dev" + ], + "values": { + "SDT_ENV_PROFILE": "release", + "SDT_LOG_LEVEL": "warning" + } + } + ] + }, + "toolchains": { + "python": { + "executable": "python3.14", + "windowsExecutable": "py", + "launcherVersion": "-3.14", + "venvDir": ".venv", + "profiles": [ + { + "id": "cpu", + "label": "CPU only (default)", + "requirementsFile": "requirements_cpu_only.txt", + "extraIndexUrl": "https://download.pytorch.org/whl/cpu", + "postInstallCommands": [] + }, + { + "id": "gpu", + "label": "GPU / CUDA", + "requirementsFile": "requirements_gpu.txt", + "extraIndexUrl": null, + "postInstallCommands": [] + }, + { + "id": "nlp", + "label": "NLP / spaCy (optional)", + "requirementsFile": "requirements_nlp_optional.txt", + "extraIndexUrl": null, + "postInstallCommands": [ + "spacy download en_core_web_sm" + ] + } + ], + "pipScript": "scripts/pip-min.ps1" + }, + "node": { + "packageManager": "npm", + "workingDir": "Journal.App" + } + }, + "tooling": { + "tools": [ + { + "tool": "cargo", + "preferredInstallCommands": [], + "executables": [] + }, + { + "tool": "dotnet", + "preferredInstallCommands": [], + "executables": [] + }, + { + "tool": "node", + "preferredInstallCommands": [], + "executables": [] + }, + { + "tool": "npm", + "preferredInstallCommands": [], + "executables": [] + }, + { + "tool": "python", + "preferredInstallCommands": [], + "executables": [] + } + ] + }, + "project": null, + "debug": { + "profiles": [], + "diagnostics": { + "enabled": true, + "outputDir": ".sdt/debug", + "includeAllEnv": false, + "captureEnvKeys": [], + "redactSensitive": true, + "sensitiveKeyPatterns": [ + "TOKEN", + "SECRET", + "PASSWORD", + "PWD", + "CREDENTIAL", + "API_KEY", + "ACCESS_KEY", + "PRIVATE_KEY" + ], + "redactionAllowKeys": [], + "bundleOnFailure": true + } + } +} \ No newline at end of file diff --git a/devtool.json.bak-20260301-155008 b/devtool.json.bak-20260301-155008 new file mode 100644 index 0000000..0ef1cdd --- /dev/null +++ b/devtool.json.bak-20260301-155008 @@ -0,0 +1,677 @@ +{ + "name": "Project Journal", + "version": "0.1.0", + "targets": [], + "workflows": [ + { + "id": "sidecar", + "label": "Publish Sidecar", + "description": "Build Journal.Sidecar as self-contained exe \u2192 output/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "sidecar:run", + "label": "Publish Sidecar", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-sidecar.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "web", + "label": "Build Web UI", + "description": "Build SvelteKit bundle \u2192 Journal.App/build/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "web:run", + "label": "Build Web UI", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "web" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "sync-output", + "label": "Sync Build Assets to Output", + "description": "Sweep repo for newest builds (Web/Sidecar/Gateway/Tauri) and copy them to the output dir", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "sync-output:run", + "label": "Sync Build Assets to Output", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/sync-output.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "webgateway", + "label": "Publish WebGateway", + "description": "Publish ASP.NET host with embedded web UI \u2192 output/webgateway/", + "group": "Build", + "dependsOn": [ + "web" + ], + "steps": [ + { + "id": "webgateway:run", + "label": "Publish WebGateway", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-webgateway.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "tauri", + "label": "Build Tauri Desktop App", + "description": "Build desktop exe (no installer) \u2192 Journal.App/src-tauri/target/release/", + "group": "Build", + "dependsOn": [ + "sidecar" + ], + "steps": [ + { + "id": "tauri:run", + "label": "Build Tauri Desktop App", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "none" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "tauri-nsis", + "label": "Build Tauri \u002B NSIS Installer", + "description": "Build desktop exe with NSIS installer package", + "group": "Build", + "dependsOn": [ + "sidecar" + ], + "steps": [ + { + "id": "tauri-nsis:run", + "label": "Build Tauri \u002B NSIS Installer", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "nsis" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "build-dotnet", + "label": "Build .NET Projects", + "description": "dotnet build \u2014 all C# projects in solution", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "build-dotnet:run", + "label": "Build .NET Projects", + "command": "dotnet", + "args": [ + "build" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "all", + "label": "Full Release Build \u2726", + "description": "Sidecar \u2192 Web \u2192 WebGateway \u2192 Tauri, in dependency order", + "group": "Build", + "dependsOn": [ + "sidecar", + "web", + "webgateway", + "tauri" + ], + "steps": [] + }, + { + "id": "run-gateway-dev", + "label": "Run WebGateway Server (Dev)", + "description": "Start HTTP gateway via \u0027dotnet run\u0027 at http://localhost:5180", + "group": "Dev", + "dependsOn": [], + "steps": [ + { + "id": "run-gateway-dev:run", + "label": "Run WebGateway Server (Dev)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/run-webgateway.ps1", + "-Mode", + "Dev" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "run-gateway-prod", + "label": "Run WebGateway Server (Output)", + "description": "Start compiled gateway from output/webgateway at http://localhost:5180", + "group": "Dev", + "dependsOn": [], + "steps": [ + { + "id": "run-gateway-prod:run", + "label": "Run WebGateway Server (Output)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/run-webgateway.ps1", + "-Mode", + "Output" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "test", + "label": "Run Smoke Tests", + "description": "Run all ~80 integration tests in Journal.SmokeTests", + "group": "Test", + "dependsOn": [], + "steps": [ + { + "id": "test:run", + "label": "Run Smoke Tests", + "command": "dotnet", + "args": [ + "run", + "--project", + "Journal.SmokeTests/Journal.SmokeTests.csproj" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "gate", + "label": "Run Migration Gate", + "description": "Full build \u002B smoke tests \u002B parity check", + "group": "Test", + "dependsOn": [], + "steps": [ + { + "id": "gate:run", + "label": "Run Migration Gate", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/migration-gate.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "nuget-export", + "label": "Export NuGet Cache", + "description": "Prime and export .nuget cache to zip for offline use", + "group": "Cache", + "dependsOn": [], + "steps": [ + { + "id": "nuget-export:run", + "label": "Export NuGet Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-export-cache.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "nuget-import", + "label": "Import NuGet Cache", + "description": "Import cache zip and validate restore", + "group": "Cache", + "dependsOn": [], + "steps": [ + { + "id": "nuget-import:run", + "label": "Import NuGet Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-import-cache.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "npm-clean", + "label": "Clean Node Modules", + "description": "Remove Journal.App node_modules (kills node/tauri first)", + "group": "System", + "dependsOn": [], + "steps": [ + { + "id": "npm-clean:run", + "label": "Clean Node Modules", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/npm-clean.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "stage-output", + "label": "Stage Output Bundle", + "description": "Publish sidecar \u002B web \u002B webgateway \u002B tauri, then stage journalapp.exe into output/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "stage-output:run", + "label": "Stage Output Bundle", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-output.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + } + ], + "env": [ + { + "key": "JOURNAL_AI_PROVIDER", + "description": "AI provider bridge mode", + "default": "none", + "options": [ + "none", + "python-sidecar" + ] + }, + { + "key": "JOURNAL_LOG_LEVEL", + "description": "Log verbosity for C# backend", + "default": "warning", + "options": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical" + ] + }, + { + "key": "JOURNAL_NLP_BACKEND", + "description": "Python NLP backend selection", + "default": "auto", + "options": [ + "auto", + "spacy", + "fallback" + ] + }, + { + "key": "JOURNAL_PROJECT_ROOT", + "description": "Override project root path (blank = auto-detect)", + "default": "", + "options": [] + }, + { + "key": "JOURNAL_VAULT_DIR", + "description": "Override vault directory path", + "default": "", + "options": [] + }, + { + "key": "JOURNAL_DATA_DIR", + "description": "Override decrypted data directory path", + "default": "", + "options": [] + } + ], + "toolchains": { + "python": { + "executable": "python3.14", + "windowsExecutable": "py", + "launcherVersion": "-3.14", + "venvDir": ".venv", + "profiles": [ + { + "id": "cpu", + "label": "CPU only (default)", + "requirementsFile": "requirements_cpu_only.txt", + "extraIndexUrl": "https://download.pytorch.org/whl/cpu", + "postInstallCommands": [] + }, + { + "id": "gpu", + "label": "GPU / CUDA", + "requirementsFile": "requirements_gpu.txt", + "extraIndexUrl": null, + "postInstallCommands": [] + }, + { + "id": "nlp", + "label": "NLP / spaCy (optional)", + "requirementsFile": "requirements_nlp_optional.txt", + "extraIndexUrl": null, + "postInstallCommands": [ + "spacy download en_core_web_sm" + ] + } + ], + "pipScript": "scripts/pip-min.ps1" + }, + "node": { + "packageManager": "npm", + "workingDir": "Journal.App" + } + }, + "tooling": null, + "project": null, + "debug": null +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8d53e23 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1937 @@ +{ + "name": "journal", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "journal", + "workspaces": [ + "Journal.App", + "Journal.DevTool" + ] + }, + "Journal.App": { + "name": "journalapp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-opener": "^2", + "tauri-plugin-mic-recorder-api": "^2.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tauri-apps/cli": "^2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } + }, + "Journal.DevTool": { + "dependencies": { + "tauri-plugin-mic-recorder-api": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.53.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", + "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.3", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz", + "integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.0", + "@tauri-apps/cli-darwin-x64": "2.10.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", + "@tauri-apps/cli-linux-arm64-musl": "2.10.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-musl": "2.10.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", + "@tauri-apps/cli-win32-x64-msvc": "2.10.0" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz", + "integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", + "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", + "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", + "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", + "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", + "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", + "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", + "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", + "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", + "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", + "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", + "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", + "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/Journal.DevTool": { + "resolved": "Journal.DevTool", + "link": true + }, + "node_modules/journalapp": { + "resolved": "Journal.App", + "link": true + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.0.tgz", + "integrity": "sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.53.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", + "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.4.tgz", + "integrity": "sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tauri-plugin-mic-recorder-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tauri-plugin-mic-recorder-api/-/tauri-plugin-mic-recorder-api-2.0.0.tgz", + "integrity": "sha512-04wqYCX4WIlYd6KUY7aS3+W4B5RtnSoVczaQCBSXKpQkEx9XdaaBN05XCee2unxGva0btSXBItFqQSdosnS4jQ==", + "license": "MIT", + "dependencies": { + "@tauri-apps/api": ">=2.0.0-beta.6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..470bab3 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "journal", + "private": true, + "workspaces": [ + "Journal.App" + ] +} \ No newline at end of file