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 <agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-02-21 02:01:00 -06:00
commit d0fac4199a
22 changed files with 720 additions and 0 deletions

5
.editorconfig Normal file
View File

@ -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

36
.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@Journal.Api_HostAddress = http://localhost:5014
GET {{Journal.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

41
Journal.Api/Program.cs Normal file
View File

@ -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);
}

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace Journal.Core.Dtos;
public record FragmentDto(
Guid Id,
string Type,
string Description,
DateTimeOffset Time,
List<string> Tags
);
public record CreateFragmentDto(
[property: Required(AllowEmptyStrings = false)] string Type,
[property: Required(AllowEmptyStrings = false)] string Description,
List<string>? Tags = null
);
public record UpdateFragmentDto(
string? Type = null,
string? Description = null,
List<string>? Tags = null,
DateTimeOffset? Time = null
);

58
Journal.Core/Entry.cs Normal file
View File

@ -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<string> HandleCommand(string json)
{
try
{
var cmd = JsonSerializer.Deserialize<Command>(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<CreateFragmentDto>()!),
"fragments.update" => await _fragments.UpdateAsync(
Guid.Parse(cmd.Id!),
cmd.Payload!.Value.Deserialize<UpdateFragmentDto>()!),
"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 });
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
</ItemGroup>
</Project>

View File

@ -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; }
}

View File

@ -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<string> 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;
}
}

View File

@ -0,0 +1,15 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public interface IFragmentRepository
{
Task<List<Fragment>> GetAllAsync();
Task<Fragment?> GetByIdAsync(Guid id);
Task AddAsync(Fragment fragment);
Task<bool> RemoveAsync(Guid id);
Task<bool> UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null);
Task<List<Fragment>> GetByTagAsync(string tag);
Task<List<Fragment>> GetByTypeAsync(string type);
Task<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
}

View File

@ -0,0 +1,126 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public class InMemoryFragmentRepository : IFragmentRepository
{
private readonly List<Fragment> _store = [];
private readonly Lock _lock = new();
public Task<List<Fragment>> GetAllAsync()
{
lock (_lock)
{
return Task.FromResult(_store.ToList());
}
}
public Task<Fragment?> 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<bool> 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<bool> UpdateAsync(Guid id, string? type = null, string? description = null, IEnumerable<string>? 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<List<Fragment>> GetByTagAsync(string tag)
{
var q = tag?.Trim();
if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List<Fragment>());
lock (_lock)
{
return Task.FromResult(_store.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(q, StringComparison.OrdinalIgnoreCase)) == true).ToList());
}
}
public Task<List<Fragment>> GetByTypeAsync(string type)
{
var q = type?.Trim();
if (string.IsNullOrWhiteSpace(q)) return Task.FromResult(new List<Fragment>());
lock (_lock)
{
return Task.FromResult(_store.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(q, StringComparison.OrdinalIgnoreCase)).ToList());
}
}
public Task<List<Fragment>> 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());
}
}
}

View File

@ -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<IFragmentRepository, InMemoryFragmentRepository>();
services.AddTransient<IFragmentService, FragmentService>();
return services;
}
}

View File

@ -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<FragmentDto> 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<bool> 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<bool> RemoveAsync(Guid id) => _repo.RemoveAsync(id);
public async Task<List<FragmentDto>> 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<List<FragmentDto>> GetByTagAsync(string tag)
{
var items = await _repo.GetByTagAsync(tag);
return [.. items.Select(Map)];
}
public async Task<List<FragmentDto>> GetByTypeAsync(string type)
{
var items = await _repo.GetByTypeAsync(type);
return [.. items.Select(Map)];
}
public async Task<List<FragmentDto>> GetAllAsync()
{
var items = await _repo.GetAllAsync();
return [.. items.Select(Map)];
}
public async Task<FragmentDto?> GetByIdAsync(Guid id)
{
var f = await _repo.GetByIdAsync(id);
return f is null ? null : Map(f);
}
}

View File

@ -0,0 +1,15 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
public interface IFragmentService
{
Task<FragmentDto> CreateAsync(CreateFragmentDto dto);
Task<bool> UpdateAsync(Guid id, UpdateFragmentDto dto);
Task<bool> RemoveAsync(Guid id);
Task<List<FragmentDto>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
Task<List<FragmentDto>> GetByTagAsync(string tag);
Task<List<FragmentDto>> GetByTypeAsync(string type);
Task<List<FragmentDto>> GetAllAsync();
Task<FragmentDto?> GetByIdAsync(Guid id);
}

10
Journal.Sidecar/App.cs Normal file
View File

@ -0,0 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using Journal.Core;
var services = new ServiceCollection();
services.AddFragmentServices();
services.AddSingleton<Entry>();
var provider = services.BuildServiceProvider();
var entry = provider.GetRequiredService<Entry>();
await entry.RunAsync();

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
</ItemGroup>
</Project>

5
Journal.slnx Normal file
View File

@ -0,0 +1,5 @@
<Solution>
<Project Path="Journal.Api/Journal.Api.csproj" />
<Project Path="Journal.Core/Journal.Core.csproj" />
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
</Solution>

166
README.md Normal file
View File

@ -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)