commit d0fac4199a79b41e339d23092cae3e032989c69e Author: Jacob Schmidt Date: Sat Feb 21 02:01:00 2026 -0600 Initial commit: Journal.Core library + Sidecar console app - Fragment model with validation, DTOs (immutable records), repository, service - Sidecar stdin/stdout JSON protocol for Tauri integration - DI wiring via ServiceCollectionExtensions - Scaffolded Journal.Api (not yet wired) Co-Authored-By: Warp 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..6f18507 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# 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 + +# Publish output +publish/ + +# User secrets +secrets.json + +# Windows +Thumbs.db +desktop.ini + +# macOS +.DS_Store diff --git a/Journal.Api/Journal.Api.csproj b/Journal.Api/Journal.Api.csproj new file mode 100644 index 0000000..d1cee09 --- /dev/null +++ b/Journal.Api/Journal.Api.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Journal.Api/Journal.Api.http b/Journal.Api/Journal.Api.http new file mode 100644 index 0000000..c02bf9c --- /dev/null +++ b/Journal.Api/Journal.Api.http @@ -0,0 +1,6 @@ +@Journal.Api_HostAddress = http://localhost:5014 + +GET {{Journal.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Journal.Api/Program.cs b/Journal.Api/Program.cs new file mode 100644 index 0000000..8000192 --- /dev/null +++ b/Journal.Api/Program.cs @@ -0,0 +1,41 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast"); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/Journal.Api/Properties/launchSettings.json b/Journal.Api/Properties/launchSettings.json new file mode 100644 index 0000000..76f7662 --- /dev/null +++ b/Journal.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5014", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7086;http://localhost:5014", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Journal.Api/appsettings.Development.json b/Journal.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Journal.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Journal.Api/appsettings.json b/Journal.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Journal.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} 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/Entry.cs b/Journal.Core/Entry.cs new file mode 100644 index 0000000..0cdac0a --- /dev/null +++ b/Journal.Core/Entry.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Services; + +namespace Journal.Core; + +public class Entry +{ + private readonly IFragmentService _fragments; + + public Entry(IFragmentService fragments) => _fragments = fragments; + + public async Task RunAsync() + { + string? line; + while ((line = Console.ReadLine()) is not null) + { + var response = await HandleCommand(line); + Console.WriteLine(response); + } + } + + private async Task HandleCommand(string json) + { + try + { + var cmd = JsonSerializer.Deserialize(json); + if (cmd is null) return Error("Invalid command"); + + object? result = cmd.Action switch + { + "fragments.list" => await _fragments.GetAllAsync(), + "fragments.get" => await _fragments.GetByIdAsync(Guid.Parse(cmd.Id!)), + "fragments.create" => await _fragments.CreateAsync( + cmd.Payload!.Value.Deserialize()!), + "fragments.update" => await _fragments.UpdateAsync( + Guid.Parse(cmd.Id!), + cmd.Payload!.Value.Deserialize()!), + "fragments.delete" => await _fragments.RemoveAsync(Guid.Parse(cmd.Id!)), + "fragments.search" => await _fragments.SearchAsync(cmd.Type, cmd.Tag), + _ => null + }; + + if (result is null) + return Error($"Unknown action: {cmd.Action}"); + + return JsonSerializer.Serialize(new { ok = true, data = result }); + } + catch (Exception ex) + { + return Error(ex.Message); + } + } + + private static string Error(string message) + => JsonSerializer.Serialize(new { ok = false, error = message }); +} diff --git a/Journal.Core/Journal.Core.csproj b/Journal.Core/Journal.Core.csproj new file mode 100644 index 0000000..5831e53 --- /dev/null +++ b/Journal.Core/Journal.Core.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Journal.Core/Models/Command.cs b/Journal.Core/Models/Command.cs new file mode 100644 index 0000000..435345a --- /dev/null +++ b/Journal.Core/Models/Command.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace Journal.Core.Models; + +public class Command +{ + public string Action { 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/Fragment.cs b/Journal.Core/Models/Fragment.cs new file mode 100644 index 0000000..7e6ac26 --- /dev/null +++ b/Journal.Core/Models/Fragment.cs @@ -0,0 +1,23 @@ +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) + { + if (string.IsNullOrWhiteSpace(type)) + throw new ArgumentException("Type is required", nameof(type)); + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description is required", nameof(description)); + + Id = Guid.NewGuid(); + Type = type.Trim(); + Description = description.Trim(); + Time = DateTimeOffset.Now; + } +} diff --git a/Journal.Core/Repositories/IFragmentRepository.cs b/Journal.Core/Repositories/IFragmentRepository.cs new file mode 100644 index 0000000..54011c1 --- /dev/null +++ b/Journal.Core/Repositories/IFragmentRepository.cs @@ -0,0 +1,15 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public interface IFragmentRepository +{ + Task> GetAllAsync(); + Task GetByIdAsync(Guid id); + Task AddAsync(Fragment fragment); + Task RemoveAsync(Guid id); + Task UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable? tags = null, DateTimeOffset? time = null); + Task> GetByTagAsync(string tag); + Task> GetByTypeAsync(string type); + Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); +} diff --git a/Journal.Core/Repositories/InMemoryFragmentRepository.cs b/Journal.Core/Repositories/InMemoryFragmentRepository.cs new file mode 100644 index 0000000..d283645 --- /dev/null +++ b/Journal.Core/Repositories/InMemoryFragmentRepository.cs @@ -0,0 +1,126 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public class InMemoryFragmentRepository : IFragmentRepository +{ + private readonly List _store = []; + private readonly Lock _lock = new(); + + public Task> GetAllAsync() + { + lock (_lock) + { + return Task.FromResult(_store.ToList()); + } + } + + public Task GetByIdAsync(Guid id) + { + lock (_lock) + { + return Task.FromResult(_store.FirstOrDefault(f => f.Id == id)); + } + } + + public Task AddAsync(Fragment fragment) + { + if (fragment is null) throw new ArgumentNullException(nameof(fragment)); + lock (_lock) + { + if (fragment.Tags != null) + { + fragment.Tags = [.. fragment.Tags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t!.Trim())]; + } + if (!string.IsNullOrWhiteSpace(fragment.Type)) fragment.Type = fragment.Type.Trim(); + if (!string.IsNullOrWhiteSpace(fragment.Description)) fragment.Description = fragment.Description.Trim(); + + _store.Add(fragment); + } + return Task.CompletedTask; + } + + public Task RemoveAsync(Guid id) + { + lock (_lock) + { + var item = _store.FirstOrDefault(f => f.Id == id); + if (item is null) return Task.FromResult(false); + return Task.FromResult(_store.Remove(item)); + } + } + + public Task UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable? tags = null, DateTimeOffset? time = null) + { + lock (_lock) + { + var item = _store.FirstOrDefault(f => f.Id == id); + if (item is null) return Task.FromResult(false); + + if (type != null) + { + if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Type cannot be empty", nameof(type)); + item.Type = type.Trim(); + } + + if (description != null) + { + if (string.IsNullOrWhiteSpace(description)) throw new ArgumentException("Description cannot be empty", nameof(description)); + item.Description = description.Trim(); + } + + if (tags != null) + { + item.Tags = [.. tags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t!.Trim())]; + } + + if (time.HasValue) + item.Time = time.Value; + + return Task.FromResult(true); + } + } + + public Task> GetByTagAsync(string tag) + { + var q = tag?.Trim(); + if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List()); + lock (_lock) + { + return Task.FromResult(_store.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(q, StringComparison.OrdinalIgnoreCase)) == true).ToList()); + } + } + + public Task> GetByTypeAsync(string type) + { + var q = type?.Trim(); + if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List()); + lock (_lock) + { + return Task.FromResult(_store.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(q, StringComparison.OrdinalIgnoreCase)).ToList()); + } + } + + public Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + { + var results = _store.AsEnumerable(); + var qType = type?.Trim(); + var qTag = tag?.Trim(); + + lock (_lock) + { + if (!string.IsNullOrWhiteSpace(qType)) + results = results.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(qType, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(qTag)) + results = results.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(qTag, StringComparison.OrdinalIgnoreCase)) == true); + if (timeAfter.HasValue) + results = results.Where(f => f.Time > timeAfter.Value); + + return Task.FromResult(results.ToList()); + } + } +} diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..97aa165 --- /dev/null +++ b/Journal.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Journal.Core.Repositories; +using Journal.Core.Services; + +namespace Journal.Core; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFragmentServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddTransient(); + return services; + } +} diff --git a/Journal.Core/Services/FragmentService.cs b/Journal.Core/Services/FragmentService.cs new file mode 100644 index 0000000..b435660 --- /dev/null +++ b/Journal.Core/Services/FragmentService.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; + +namespace Journal.Core.Services; + +public class FragmentService : IFragmentService +{ + private readonly IFragmentRepository _repo; + + public FragmentService(IFragmentRepository repo) => _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 async Task CreateAsync(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())]; + + await _repo.AddAsync(f); + return Map(f); + } + + public async Task UpdateAsync(Guid id, UpdateFragmentDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Type != null) + throw new ValidationException("Type cannot be empty"); + if (dto.Description != null && string.IsNullOrWhiteSpace(dto.Description)) + throw new ValidationException("Description cannot be empty"); + + return await _repo.UpdateAsync(id, dto.Type, dto.Description, dto.Tags, dto.Time); + } + + public Task RemoveAsync(Guid id) => _repo.RemoveAsync(id); + + public async Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null) + { + var items = await _repo.SearchAsync(type, tag, timeAfter); + return [.. items.Select(Map)]; + } + + public async Task> GetByTagAsync(string tag) + { + var items = await _repo.GetByTagAsync(tag); + return [.. items.Select(Map)]; + } + + public async Task> GetByTypeAsync(string type) + { + var items = await _repo.GetByTypeAsync(type); + return [.. items.Select(Map)]; + } + + public async Task> GetAllAsync() + { + var items = await _repo.GetAllAsync(); + return [.. items.Select(Map)]; + } + + public async Task GetByIdAsync(Guid id) + { + var f = await _repo.GetByIdAsync(id); + return f is null ? null : Map(f); + } +} diff --git a/Journal.Core/Services/IFragmentService.cs b/Journal.Core/Services/IFragmentService.cs new file mode 100644 index 0000000..2bde778 --- /dev/null +++ b/Journal.Core/Services/IFragmentService.cs @@ -0,0 +1,15 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services; + +public interface IFragmentService +{ + Task CreateAsync(CreateFragmentDto dto); + Task UpdateAsync(Guid id, UpdateFragmentDto dto); + Task RemoveAsync(Guid id); + Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null); + Task> GetByTagAsync(string tag); + Task> GetByTypeAsync(string type); + Task> GetAllAsync(); + Task GetByIdAsync(Guid id); +} diff --git a/Journal.Sidecar/App.cs b/Journal.Sidecar/App.cs new file mode 100644 index 0000000..c62cbd5 --- /dev/null +++ b/Journal.Sidecar/App.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; +using Journal.Core; + +var services = new ServiceCollection(); +services.AddFragmentServices(); +services.AddSingleton(); +var provider = services.BuildServiceProvider(); + +var entry = provider.GetRequiredService(); +await entry.RunAsync(); \ No newline at end of file diff --git a/Journal.Sidecar/Journal.Sidecar.csproj b/Journal.Sidecar/Journal.Sidecar.csproj new file mode 100644 index 0000000..d9b4688 --- /dev/null +++ b/Journal.Sidecar/Journal.Sidecar.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/Journal.slnx b/Journal.slnx new file mode 100644 index 0000000..a5a68e3 --- /dev/null +++ b/Journal.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c3c62f --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Journal Backend (.NET) + +A .NET 10 backend for the Project Journal app. Provides core journal functionality as a class library with a sidecar console app for Tauri integration and an optional HTTP API. + +## Project Structure + +``` +backend/ +├── Journal.Core/ Class library — all business logic +│ ├── Models/ +│ │ ├── Fragment.cs Domain model (validated, owns Guid ID) +│ │ └── Command.cs Stdin command shape for sidecar protocol +│ ├── Dtos/ +│ │ └── FragmentDtos.cs Immutable records for API boundary +│ │ ├── FragmentDto Read (what goes out) +│ │ ├── CreateFragmentDto Create (what comes in) +│ │ └── UpdateFragmentDto Update (partial, all fields optional) +│ ├── Repositories/ +│ │ ├── IFragmentRepository.cs Interface (data access contract) +│ │ └── InMemoryFragmentRepository.cs In-memory implementation +│ ├── Services/ +│ │ ├── IFragmentService.cs Interface (business logic contract) +│ │ └── FragmentService.cs Validates, calls repo, maps to DTOs +│ ├── Entry.cs Command dispatcher (stdin/stdout) +│ ├── ServiceCollectionExtensions.cs DI registration helper +│ └── Journal.Core.csproj +│ +├── Journal.Sidecar/ Console app — Tauri sidecar bridge +│ ├── App.cs Boots DI container, runs Entry.RunAsync() +│ └── Journal.Sidecar.csproj References Journal.Core +│ +├── Journal.Api/ Web API — HTTP endpoint wrapper (optional) +│ ├── Program.cs +│ └── Journal.Api.csproj +│ +└── README.md +``` + +## Architecture + +Each layer only knows about the one below it: + +``` +Sidecar (stdin/stdout) ──┐ + ├──► Services (business logic) ──► Repositories (data access) +API (HTTP/JSON) ─────────┘ +``` + +- **Models** — Domain objects with validation. The source of truth. +- **DTOs** — Immutable records that cross the API boundary. Internal logic never leaks out. +- **Repositories** — Where data lives. Swap `InMemoryFragmentRepository` for SQLite/EF Core later without touching anything above. +- **Services** — Business rules, validation, orchestration. Doesn't know about HTTP or stdin. +- **Entry** — Transport adapter. Translates stdin/stdout JSON into service calls. + +## Dependencies + +- **Journal.Core** — `Microsoft.Extensions.DependencyInjection.Abstractions` (interface-only, lightweight) +- **Journal.Sidecar** — `Microsoft.Extensions.DependencyInjection` (full container implementation) + references `Journal.Core` + +## Building + +```powershell +# Build everything (building Sidecar also rebuilds Core if changed) +dotnet build backend\Journal.Sidecar\Journal.Sidecar.csproj + +# Build just the library +dotnet build backend\Journal.Core\Journal.Core.csproj + +# Format code +dotnet format backend\Journal.Core\Journal.Core.csproj +``` + +## Publishing + +Publish as a single-file self-contained executable (no .NET runtime install needed): + +```powershell +dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true +``` + +Output: `backend\Journal.Sidecar\bin\Release\net10.0\win-x64\publish\Journal.Sidecar.exe` (~70MB, everything bundled) + +To exclude debug symbols: add `-p:DebugType=none` + +For a smaller build that requires .NET 10 on the target machine: + +```powershell +dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-x64 -p:PublishSingleFile=true +``` + +## Sidecar Protocol + +The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, one JSON line out. + +### Command Format + +```json +{ + "action": "fragments.create", + "id": null, + "type": null, + "tag": null, + "payload": { "type": "!TRIGGER", "description": "stomach drop" } +} +``` + +**Fields:** +- `action` — The operation to perform (e.g. `fragments.list`, `fragments.create`) +- `id` — Target entity ID (for get/update/delete) +- `type` / `tag` — Filter parameters (for search) +- `payload` — Request body, deserialized into the appropriate DTO per action + +### Available Actions + +| Action | Description | Requires | +|--------|-------------|----------| +| `fragments.list` | List all fragments | — | +| `fragments.get` | Get fragment by ID | `id` | +| `fragments.create` | Create a new fragment | `payload` (CreateFragmentDto) | +| `fragments.update` | Update a fragment | `id`, `payload` (UpdateFragmentDto) | +| `fragments.delete` | Delete a fragment | `id` | +| `fragments.search` | Search by type/tag | `type` and/or `tag` | + +### Response Format + +Success: +```json +{ "ok": true, "data": { "id": "abc-123", "type": "!TRIGGER", "description": "...", "time": "...", "tags": [] } } +``` + +Error: +```json +{ "ok": false, "error": "Description is required" } +``` + +## Extending with New Modules + +The `Command` class is generic — new modules use the same dot-notation pattern: + +``` +vault.unlock → IVaultService (future) +vault.lock +entries.list → IEntryService (future) +entries.create +ai.analyze → IAiService (future) +ai.chat +search.query → ISearchService (future) +``` + +To add a module: +1. Create model, DTO, repository, and service in `Journal.Core/` +2. Register the new service in `ServiceCollectionExtensions.cs` +3. Inject the service into `Entry.cs` and add cases to the action switch +4. No changes needed to `Command.cs` or `App.cs` + +## Dependency Injection + +`ServiceCollectionExtensions.cs` wires everything up. Any host (sidecar, API, tests) calls: + +```csharp +services.AddFragmentServices(); +``` + +This registers: +- `IFragmentRepository` → `InMemoryFragmentRepository` (singleton — one shared store) +- `IFragmentService` → `FragmentService` (transient — fresh instance per request)