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:
commit
d0fac4199a
5
.editorconfig
Normal file
5
.editorconfig
Normal 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
36
.gitignore
vendored
Normal 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
|
||||
13
Journal.Api/Journal.Api.csproj
Normal file
13
Journal.Api/Journal.Api.csproj
Normal 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>
|
||||
6
Journal.Api/Journal.Api.http
Normal file
6
Journal.Api/Journal.Api.http
Normal 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
41
Journal.Api/Program.cs
Normal 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);
|
||||
}
|
||||
23
Journal.Api/Properties/launchSettings.json
Normal file
23
Journal.Api/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Journal.Api/appsettings.Development.json
Normal file
8
Journal.Api/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Journal.Api/appsettings.json
Normal file
9
Journal.Api/appsettings.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
24
Journal.Core/Dtos/FragmentDtos.cs
Normal file
24
Journal.Core/Dtos/FragmentDtos.cs
Normal 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
58
Journal.Core/Entry.cs
Normal 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 });
|
||||
}
|
||||
13
Journal.Core/Journal.Core.csproj
Normal file
13
Journal.Core/Journal.Core.csproj
Normal 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>
|
||||
12
Journal.Core/Models/Command.cs
Normal file
12
Journal.Core/Models/Command.cs
Normal 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; }
|
||||
}
|
||||
23
Journal.Core/Models/Fragment.cs
Normal file
23
Journal.Core/Models/Fragment.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
15
Journal.Core/Repositories/IFragmentRepository.cs
Normal file
15
Journal.Core/Repositories/IFragmentRepository.cs
Normal 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);
|
||||
}
|
||||
126
Journal.Core/Repositories/InMemoryFragmentRepository.cs
Normal file
126
Journal.Core/Repositories/InMemoryFragmentRepository.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Journal.Core/ServiceCollectionExtensions.cs
Normal file
15
Journal.Core/ServiceCollectionExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
79
Journal.Core/Services/FragmentService.cs
Normal file
79
Journal.Core/Services/FragmentService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
Journal.Core/Services/IFragmentService.cs
Normal file
15
Journal.Core/Services/IFragmentService.cs
Normal 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
10
Journal.Sidecar/App.cs
Normal 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();
|
||||
18
Journal.Sidecar/Journal.Sidecar.csproj
Normal file
18
Journal.Sidecar/Journal.Sidecar.csproj
Normal 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
5
Journal.slnx
Normal 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
166
README.md
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user