massive backend migration to c#
This commit is contained in:
parent
d0fac4199a
commit
14b8e7a339
3
.gitignore
vendored
3
.gitignore
vendored
@ -21,6 +21,9 @@ obj/
|
||||
**/packages/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
.nuget/
|
||||
.dotnet_home/
|
||||
.journal-sidecar/
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
|
||||
@ -10,4 +10,8 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -1,6 +1,24 @@
|
||||
@Journal.Api_HostAddress = http://localhost:5014
|
||||
|
||||
GET {{Journal.Api_HostAddress}}/weatherforecast/
|
||||
GET {{Journal.Api_HostAddress}}/health
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
POST {{Journal.Api_HostAddress}}/api/command
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "config.get",
|
||||
"payload": {}
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
POST {{Journal.Api_HostAddress}}/api/command
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action":
|
||||
|
||||
###
|
||||
|
||||
@ -1,41 +1,29 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
using System.Text.Json;
|
||||
using Journal.Core;
|
||||
|
||||
// Add services to the container.
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddFragmentServices();
|
||||
builder.Services.AddSingleton<Entry>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.MapGet("/health", () => Results.Json(new { ok = true, data = "healthy" }));
|
||||
app.MapGet("/healthz", () => Results.Json(new { ok = true, data = "healthy" }));
|
||||
app.MapGet("/api/health", () => Results.Json(new { ok = true, data = "healthy" }));
|
||||
|
||||
var summaries = new[]
|
||||
// Mirrors sidecar transport semantics over HTTP.
|
||||
// request body is passed directly to Entry.HandleCommandAsync so malformed JSON
|
||||
// and payload errors return the same {ok:false,error} envelope as sidecar mode.
|
||||
app.MapPost("/api/command", async (HttpRequest request, Entry entry) =>
|
||||
{
|
||||
"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");
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var response = await entry.HandleCommandAsync(body);
|
||||
return Results.Content(response, "application/json");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
{
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
|
||||
7
Journal.Core/Dtos/AiDtos.cs
Normal file
7
Journal.Core/Dtos/AiDtos.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Journal.Core.Dtos;
|
||||
|
||||
public sealed record AiHealthDto(
|
||||
string Provider,
|
||||
bool Enabled,
|
||||
bool Healthy,
|
||||
string Message);
|
||||
17
Journal.Core/Dtos/EntrySearchDtos.cs
Normal file
17
Journal.Core/Dtos/EntrySearchDtos.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace Journal.Core.Dtos;
|
||||
|
||||
public sealed record EntrySearchRequestDto(
|
||||
string DataDirectory,
|
||||
string? Query = null,
|
||||
string? Section = null,
|
||||
string? StartDate = null,
|
||||
string? EndDate = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
IReadOnlyList<string>? Types = null,
|
||||
IReadOnlyList<string>? Checked = null,
|
||||
IReadOnlyList<string>? Unchecked = null);
|
||||
|
||||
public sealed record EntrySearchResultDto(
|
||||
string Date,
|
||||
string FileName,
|
||||
string RawContent);
|
||||
21
Journal.Core/Dtos/SpeechDtos.cs
Normal file
21
Journal.Core/Dtos/SpeechDtos.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace Journal.Core.Dtos;
|
||||
|
||||
public sealed record SpeechDeviceDto(
|
||||
int Index,
|
||||
string Name);
|
||||
|
||||
public sealed record SpeechDevicesResultDto(
|
||||
IReadOnlyList<SpeechDeviceDto> 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);
|
||||
@ -1,3 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
@ -8,51 +12,536 @@ namespace Journal.Core;
|
||||
public class Entry
|
||||
{
|
||||
private readonly IFragmentService _fragments;
|
||||
private readonly IEntrySearchService _entrySearch;
|
||||
private readonly IVaultStorageService _vaultStorage;
|
||||
private readonly IJournalDatabaseService _database;
|
||||
private readonly IJournalConfigService _config;
|
||||
private readonly IAiService _ai;
|
||||
private readonly ISpeechBridgeService _speech;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public Entry(IFragmentService fragments) => _fragments = fragments;
|
||||
public Entry(
|
||||
IFragmentService fragments,
|
||||
IEntrySearchService entrySearch,
|
||||
IVaultStorageService vaultStorage,
|
||||
IJournalDatabaseService database,
|
||||
IJournalConfigService config,
|
||||
IAiService ai,
|
||||
ISpeechBridgeService speech)
|
||||
{
|
||||
_fragments = fragments;
|
||||
_entrySearch = entrySearch;
|
||||
_vaultStorage = vaultStorage;
|
||||
_database = database;
|
||||
_config = config;
|
||||
_ai = ai;
|
||||
_speech = speech;
|
||||
}
|
||||
|
||||
public async Task RunAsync()
|
||||
{
|
||||
string? line;
|
||||
while ((line = Console.ReadLine()) is not null)
|
||||
{
|
||||
var response = await HandleCommand(line);
|
||||
var response = await HandleCommandAsync(line);
|
||||
Console.WriteLine(response);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> HandleCommand(string json)
|
||||
public async Task<string> HandleCommandAsync(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Error("Invalid command");
|
||||
|
||||
Command? cmd;
|
||||
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 });
|
||||
cmd = JsonSerializer.Deserialize<Command>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
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();
|
||||
LogStart(action, correlationId, cmd.Payload);
|
||||
object? result;
|
||||
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "fragments.list":
|
||||
result = await _fragments.GetAllAsync();
|
||||
break;
|
||||
case "fragments.get":
|
||||
if (!Guid.TryParse(cmd.Id, out var getId))
|
||||
return Error("Invalid or missing id");
|
||||
result = await _fragments.GetByIdAsync(getId);
|
||||
break;
|
||||
case "fragments.create":
|
||||
var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload);
|
||||
if (createDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = await _fragments.CreateAsync(createDto);
|
||||
break;
|
||||
case "fragments.update":
|
||||
if (!Guid.TryParse(cmd.Id, out var updateId))
|
||||
return Error("Invalid or missing id");
|
||||
var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload);
|
||||
if (updateDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = await _fragments.UpdateAsync(updateId, updateDto);
|
||||
break;
|
||||
case "fragments.delete":
|
||||
if (!Guid.TryParse(cmd.Id, out var deleteId))
|
||||
return Error("Invalid or missing id");
|
||||
result = await _fragments.RemoveAsync(deleteId);
|
||||
break;
|
||||
case "fragments.search":
|
||||
result = await _fragments.SearchAsync(cmd.Type, cmd.Tag);
|
||||
break;
|
||||
case "search.entries":
|
||||
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
|
||||
if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory))
|
||||
return Error("Missing or invalid payload");
|
||||
var searchRequest = new EntrySearchRequestDto(
|
||||
DataDirectory: searchPayload.DataDirectory,
|
||||
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":
|
||||
var listPayload = DeserializePayload<EntryListPayload>(cmd.Payload);
|
||||
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
|
||||
? listPayload.DataDirectory
|
||||
: _config.Current.DataDirectory;
|
||||
result = ListEntries(listDataDirectory);
|
||||
break;
|
||||
case "entries.load":
|
||||
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
|
||||
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
|
||||
return Error("Missing or invalid payload");
|
||||
result = LoadEntry(loadEntryPayload.FilePath);
|
||||
break;
|
||||
case "entries.save":
|
||||
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
|
||||
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
|
||||
return Error("Missing or invalid payload");
|
||||
result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
||||
break;
|
||||
case "config.get":
|
||||
result = _config.Current;
|
||||
break;
|
||||
case "ai.health":
|
||||
result = await _ai.HealthAsync();
|
||||
break;
|
||||
case "ai.summarize_entry":
|
||||
var summarizeEntryPayload = DeserializePayload<AiSummarizeEntryPayload>(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<AiSummarizeAllPayload>(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<AiChatPayload>(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<AiEmbedPayload>(cmd.Payload);
|
||||
if (embedPayload is null || string.IsNullOrWhiteSpace(embedPayload.Content))
|
||||
return Error("Missing or invalid payload");
|
||||
result = await _ai.EmbedAsync(embedPayload.Content);
|
||||
break;
|
||||
case "speech.devices.list":
|
||||
result = await _speech.ListDevicesAsync();
|
||||
break;
|
||||
case "speech.transcribe":
|
||||
var speechPayload = DeserializePayload<SpeechTranscribePayload>(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 "vault.initialize":
|
||||
var initPayload = DeserializePayload<VaultInitializePayload>(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<VaultPayload>(cmd.Payload);
|
||||
if (loadPayload is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
|
||||
break;
|
||||
case "vault.save_current_month":
|
||||
var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload);
|
||||
if (saveCurrentPayload is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _vaultStorage.SaveCurrentMonthVault(
|
||||
saveCurrentPayload.Password,
|
||||
saveCurrentPayload.VaultDirectory,
|
||||
saveCurrentPayload.DataDirectory,
|
||||
ParseNowOrDefault(saveCurrentPayload.NowUtc));
|
||||
break;
|
||||
case "vault.rebuild_all":
|
||||
var rebuildPayload = DeserializePayload<VaultPayload>(cmd.Payload);
|
||||
if (rebuildPayload is null)
|
||||
return Error("Missing or invalid payload");
|
||||
_vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory);
|
||||
result = true;
|
||||
break;
|
||||
case "vault.clear_data_directory":
|
||||
var clearPayload = DeserializePayload<ClearDataPayload>(cmd.Payload);
|
||||
if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory))
|
||||
return Error("Missing or invalid payload");
|
||||
_vaultStorage.ClearDataDirectory(clearPayload.DataDirectory);
|
||||
result = true;
|
||||
break;
|
||||
case "db.status":
|
||||
var dbStatusPayload = DeserializePayload<DatabasePayload>(cmd.Payload);
|
||||
if (dbStatusPayload is null || string.IsNullOrWhiteSpace(dbStatusPayload.Password))
|
||||
return Error("Missing or invalid payload");
|
||||
result = _database.GetStatus(dbStatusPayload.Password, dbStatusPayload.DataDirectory);
|
||||
break;
|
||||
case "db.initialize_schema":
|
||||
var dbInitPayload = DeserializePayload<DatabasePayload>(cmd.Payload);
|
||||
if (dbInitPayload is null)
|
||||
return Error("Missing or invalid payload");
|
||||
var schemaPath = _database.WriteSchemaBootstrap(dbInitPayload.DataDirectory);
|
||||
result = new { schemaPath };
|
||||
break;
|
||||
case "db.hydrate_workspace":
|
||||
var dbHydratePayload = DeserializePayload<DatabasePayload>(cmd.Payload);
|
||||
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
|
||||
return Error("Missing or invalid payload");
|
||||
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
|
||||
break;
|
||||
default:
|
||||
LogFailure(action, correlationId, "unknown_action");
|
||||
return Error($"Unknown action: {action}");
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
LogFailure(action, correlationId, "invalid_payload_json");
|
||||
return Error("Missing or invalid payload");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "validation", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "argument", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "timeout", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "invalid_operation", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "not_found", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
LogFailure(action, correlationId, "internal_error");
|
||||
return Error("Internal error");
|
||||
}
|
||||
|
||||
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 void LogStart(string action, string correlationId, JsonElement? payload)
|
||||
{
|
||||
var redactedPayload = LogRedactor.RedactPayload(payload);
|
||||
EmitLog("information", action, correlationId, "start", redactedPayload);
|
||||
}
|
||||
|
||||
private void LogSuccess(string action, string correlationId)
|
||||
{
|
||||
EmitLog("information", action, correlationId, "success");
|
||||
}
|
||||
|
||||
private 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 = nameof(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
|
||||
};
|
||||
|
||||
private static T? DeserializePayload<T>(JsonElement? payload)
|
||||
{
|
||||
if (payload is null)
|
||||
return default;
|
||||
return payload.Value.Deserialize<T>(JsonOptions);
|
||||
}
|
||||
|
||||
private static DateTime ParseNowOrDefault(string? nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nowUtc))
|
||||
return DateTime.UtcNow;
|
||||
|
||||
if (DateTime.TryParse(
|
||||
nowUtc,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time.");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
|
||||
{
|
||||
if (!Directory.Exists(dataDirectory))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(dataDirectory, "*.md")
|
||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||
.Select(path => new EntryListItem(
|
||||
FileName: Path.GetFileName(path),
|
||||
FilePath: Path.GetFullPath(path)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static EntryLoadResult LoadEntry(string filePath)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(filePath);
|
||||
if (!File.Exists(normalizedPath))
|
||||
throw new FileNotFoundException($"Entry file not found: {normalizedPath}");
|
||||
|
||||
var rawContent = StripRichHtml(File.ReadAllText(normalizedPath));
|
||||
var fileStem = Path.GetFileNameWithoutExtension(normalizedPath);
|
||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||
|
||||
return new EntryLoadResult(
|
||||
Date: entry.Date,
|
||||
FileName: Path.GetFileName(normalizedPath),
|
||||
FilePath: normalizedPath,
|
||||
RawContent: entry.RawContent);
|
||||
}
|
||||
|
||||
private static EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
||||
{
|
||||
var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory);
|
||||
var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
|
||||
var sanitizedContent = StripRichHtml(payload.Content ?? "");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
|
||||
if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.WriteAllText(targetPath, sanitizedContent);
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.AppendAllText(targetPath, "\n\n" + sanitizedContent.Trim());
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
string finalContent;
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
var existingContent = File.ReadAllText(targetPath);
|
||||
var fileStem = Path.GetFileNameWithoutExtension(targetPath);
|
||||
var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem);
|
||||
var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem);
|
||||
existingEntry.MergeWith(newEntryData);
|
||||
finalContent = existingEntry.ToMarkdown();
|
||||
}
|
||||
else
|
||||
{
|
||||
finalContent = sanitizedContent;
|
||||
}
|
||||
|
||||
File.WriteAllText(targetPath, finalContent);
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
private static string ResolveTargetPath(string? filePath, string defaultDataDirectory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
return Path.GetFullPath(filePath);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"));
|
||||
}
|
||||
|
||||
private static bool LooksLikeRichHtml(string content)
|
||||
{
|
||||
var lowered = content.ToLowerInvariant();
|
||||
string[] markers =
|
||||
[
|
||||
"<p", "</p>", "<div", "<span", "<table", "<tr", "<td", "<li", "<ul", "<ol",
|
||||
"style=", "font-family:", "-webkit-text-stroke"
|
||||
];
|
||||
if (markers.Any(marker => lowered.Contains(marker, StringComparison.Ordinal)))
|
||||
return true;
|
||||
return Regex.Matches(lowered, "</?[a-z][^>]*>").Count >= 8;
|
||||
}
|
||||
|
||||
private 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 = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
text = Regex.Replace(text, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<li\\b[^>]*>", "\n- ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</li>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</(td|th)>", " ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<hr\\b[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline);
|
||||
text = WebUtility.HtmlDecode(text)
|
||||
.Replace('\u00a0', ' ')
|
||||
.Replace("\u200b", "", StringComparison.Ordinal);
|
||||
text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd()));
|
||||
text = Regex.Replace(text, "[ \\t]{2,}", " ");
|
||||
text = Regex.Replace(text, "\n{3,}", "\n\n").Trim();
|
||||
return string.IsNullOrEmpty(text) ? content : text;
|
||||
}
|
||||
|
||||
private sealed record VaultInitializePayload(string Password, string VaultDirectory);
|
||||
private sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
|
||||
private sealed record ClearDataPayload(string DataDirectory);
|
||||
private sealed record EntryListPayload(string? DataDirectory = null);
|
||||
private sealed record EntryLoadPayload(string FilePath);
|
||||
private sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
|
||||
private sealed record EntryListItem(string FileName, string FilePath);
|
||||
private sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
|
||||
private sealed record EntrySaveResult(string FilePath);
|
||||
private sealed record DatabasePayload(string Password, string? DataDirectory = null);
|
||||
private sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
||||
private sealed record AiSummarizeAllPayload(List<string>? Entries);
|
||||
private sealed record AiChatPayload(string Prompt);
|
||||
private sealed record AiEmbedPayload(string Content);
|
||||
private 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);
|
||||
private sealed record SearchEntriesPayload(
|
||||
string DataDirectory,
|
||||
string? Query = null,
|
||||
string? Section = null,
|
||||
string? StartDate = null,
|
||||
string? EndDate = null,
|
||||
List<string>? Tags = null,
|
||||
List<string>? Types = null,
|
||||
List<string>? Checked = null,
|
||||
List<string>? Unchecked = null);
|
||||
}
|
||||
|
||||
@ -7,7 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -5,6 +5,7 @@ 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; }
|
||||
|
||||
@ -10,14 +10,33 @@ public class Fragment
|
||||
|
||||
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));
|
||||
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<string>? 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));
|
||||
}
|
||||
}
|
||||
|
||||
29
Journal.Core/Models/JournalConfig.cs
Normal file
29
Journal.Core/Models/JournalConfig.cs
Normal file
@ -0,0 +1,29 @@
|
||||
namespace Journal.Core.Models;
|
||||
|
||||
public sealed record JournalConfig(
|
||||
string ProjectRoot,
|
||||
string AppDirectory,
|
||||
string DataDirectory,
|
||||
string VaultDirectory,
|
||||
string LogDirectory,
|
||||
string PidFile,
|
||||
string ServerControlFile,
|
||||
string DatabaseFilename,
|
||||
string MonthlyVaultFormat,
|
||||
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 PythonExecutable,
|
||||
string PythonAiSidecarPath,
|
||||
int AiSidecarTimeoutMs);
|
||||
98
Journal.Core/Models/JournalEntry.cs
Normal file
98
Journal.Core/Models/JournalEntry.cs
Normal file
@ -0,0 +1,98 @@
|
||||
namespace Journal.Core.Models;
|
||||
|
||||
public class JournalEntry
|
||||
{
|
||||
public string Date { get; set; }
|
||||
public List<Fragment> Fragments { get; set; }
|
||||
public string RawContent { get; set; }
|
||||
public Dictionary<string, ParsedSection> Sections { get; set; }
|
||||
|
||||
public JournalEntry(
|
||||
string date,
|
||||
IEnumerable<Fragment>? fragments = null,
|
||||
string rawContent = "",
|
||||
IDictionary<string, ParsedSection>? 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<string, ParsedSection>(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<string>
|
||||
{
|
||||
"---",
|
||||
"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);
|
||||
}
|
||||
}
|
||||
21
Journal.Core/Models/ParsedSection.cs
Normal file
21
Journal.Core/Models/ParsedSection.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace Journal.Core.Models;
|
||||
|
||||
public class ParsedSection
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public List<string> Content { get; set; }
|
||||
public Dictionary<string, bool> Checkboxes { get; set; }
|
||||
|
||||
public ParsedSection(
|
||||
string title,
|
||||
IEnumerable<string>? content = null,
|
||||
IDictionary<string, bool>? 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<string, bool>(checkboxes);
|
||||
}
|
||||
}
|
||||
20
Journal.Core/Models/SectionTitles.cs
Normal file
20
Journal.Core/Models/SectionTitles.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace Journal.Core.Models;
|
||||
|
||||
public static class SectionTitles
|
||||
{
|
||||
public static readonly IReadOnlyList<string> 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",
|
||||
];
|
||||
}
|
||||
228
Journal.Core/Repositories/FileFragmentRepository.cs
Normal file
228
Journal.Core/Repositories/FileFragmentRepository.cs
Normal file
@ -0,0 +1,228 @@
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Repositories;
|
||||
|
||||
public class FileFragmentRepository : IFragmentRepository
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private readonly string _storagePath;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private readonly List<Fragment> _store;
|
||||
|
||||
public FileFragmentRepository() : this(storagePath: null)
|
||||
{
|
||||
}
|
||||
|
||||
public FileFragmentRepository(string? storagePath)
|
||||
{
|
||||
_storagePath = ResolveStoragePath(storagePath);
|
||||
_store = LoadStore(_storagePath);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fragment);
|
||||
lock (_lock)
|
||||
{
|
||||
Normalize(fragment);
|
||||
_store.Add(fragment);
|
||||
SaveStoreLocked();
|
||||
}
|
||||
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);
|
||||
|
||||
var removed = _store.Remove(item);
|
||||
if (removed)
|
||||
SaveStoreLocked();
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
SaveStoreLocked();
|
||||
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)
|
||||
{
|
||||
var items = _store
|
||||
.Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
return Task.FromResult(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<Fragment>> GetByTypeAsync(string type)
|
||||
{
|
||||
var q = type?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
return Task.FromResult(new List<Fragment>());
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var items = _store
|
||||
.Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
return Task.FromResult(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
|
||||
{
|
||||
var qType = type?.Trim();
|
||||
var qTag = tag?.Trim();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
IEnumerable<Fragment> results = _store;
|
||||
|
||||
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 Task.FromResult(results.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveStoragePath(string? storagePath)
|
||||
{
|
||||
var configured = storagePath;
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
configured = Environment.GetEnvironmentVariable("JOURNAL_FRAGMENT_STORE_PATH");
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
configured = Path.Combine(Environment.CurrentDirectory, ".journal-sidecar", "fragments.json");
|
||||
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
private List<Fragment> LoadStore(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
if (!File.Exists(path))
|
||||
return [];
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
|
||||
var docs = JsonSerializer.Deserialize<List<FragmentDocument>>(json, _jsonOptions) ?? [];
|
||||
return docs.Select(d => new Fragment(d.Id, d.Type, d.Description, d.Time, d.Tags)).ToList();
|
||||
}
|
||||
|
||||
private void SaveStoreLocked()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_storagePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var docs = _store.Select(f => new FragmentDocument
|
||||
{
|
||||
Id = f.Id,
|
||||
Type = f.Type,
|
||||
Description = f.Description,
|
||||
Time = f.Time,
|
||||
Tags = [.. f.Tags]
|
||||
}).ToList();
|
||||
var json = JsonSerializer.Serialize(docs, _jsonOptions);
|
||||
|
||||
var tempPath = _storagePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, _storagePath, overwrite: true);
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
|
||||
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())];
|
||||
}
|
||||
|
||||
private sealed class FragmentDocument
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Type { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public DateTimeOffset Time { get; init; }
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
}
|
||||
@ -8,8 +8,46 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddFragmentServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IFragmentRepository, InMemoryFragmentRepository>();
|
||||
services.AddSingleton<IFragmentRepository, FileFragmentRepository>();
|
||||
services.AddSingleton<IJournalConfigService, JournalConfigService>();
|
||||
services.AddTransient<IFragmentService, FragmentService>();
|
||||
services.AddTransient<IEntrySearchService, EntrySearchService>();
|
||||
services.AddSingleton<IVaultCryptoService, VaultCryptoService>();
|
||||
services.AddSingleton<IVaultStorageService, VaultStorageService>();
|
||||
services.AddSingleton<IJournalDatabaseService, JournalDatabaseService>();
|
||||
services.AddSingleton<IAiService>(provider =>
|
||||
{
|
||||
var config = provider.GetRequiredService<IJournalConfigService>().Current;
|
||||
if (!string.Equals(config.AiProvider, "python-sidecar", StringComparison.OrdinalIgnoreCase))
|
||||
return new DisabledAiService(config.AiProvider);
|
||||
|
||||
try
|
||||
{
|
||||
return new PythonSidecarAiService(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DisabledAiService(
|
||||
provider: "python-sidecar",
|
||||
message: $"Python AI sidecar unavailable: {ex.Message}",
|
||||
healthy: false);
|
||||
}
|
||||
});
|
||||
services.AddSingleton<ISpeechBridgeService>(provider =>
|
||||
{
|
||||
var config = provider.GetRequiredService<IJournalConfigService>().Current;
|
||||
try
|
||||
{
|
||||
return new PythonSidecarSpeechService(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DisabledSpeechBridgeService(
|
||||
provider: "python-sidecar",
|
||||
message: $"Python speech sidecar unavailable: {ex.Message}");
|
||||
}
|
||||
});
|
||||
services.AddSingleton<SidecarCli>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
32
Journal.Core/Services/DisabledAiService.cs
Normal file
32
Journal.Core/Services/DisabledAiService.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class DisabledAiService : IAiService
|
||||
{
|
||||
private readonly string _provider;
|
||||
private readonly string _message;
|
||||
private readonly bool _healthy;
|
||||
|
||||
public DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true)
|
||||
{
|
||||
_provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
|
||||
_message = string.IsNullOrWhiteSpace(message) ? "AI provider disabled." : message.Trim();
|
||||
_healthy = healthy;
|
||||
}
|
||||
|
||||
public Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message));
|
||||
|
||||
public Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_message);
|
||||
|
||||
public Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_message);
|
||||
|
||||
public Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_message);
|
||||
|
||||
public Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<double>>([]);
|
||||
}
|
||||
32
Journal.Core/Services/DisabledSpeechBridgeService.cs
Normal file
32
Journal.Core/Services/DisabledSpeechBridgeService.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class DisabledSpeechBridgeService : ISpeechBridgeService
|
||||
{
|
||||
private readonly string _provider;
|
||||
private readonly string _message;
|
||||
|
||||
public DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.")
|
||||
{
|
||||
_provider = string.IsNullOrWhiteSpace(provider) ? "none" : provider.Trim();
|
||||
_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)
|
||||
{
|
||||
if (request is null)
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
var engine = string.IsNullOrWhiteSpace(request.Engine) ? "none" : request.Engine.Trim();
|
||||
var warning = $"{_message} (provider={_provider})";
|
||||
return Task.FromResult(new SpeechTranscribeResultDto("", engine, warning));
|
||||
}
|
||||
}
|
||||
108
Journal.Core/Services/EntrySearchService.cs
Normal file
108
Journal.Core/Services/EntrySearchService.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using Journal.Core.Dtos;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public class EntrySearchService : IEntrySearchService
|
||||
{
|
||||
public Task<IReadOnlyList<EntrySearchResultDto>> SearchEntriesAsync(EntrySearchRequestDto request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (string.IsNullOrWhiteSpace(request.DataDirectory))
|
||||
throw new ArgumentException("Data directory is required.", nameof(request.DataDirectory));
|
||||
|
||||
if (!Directory.Exists(request.DataDirectory))
|
||||
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>([]);
|
||||
|
||||
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 results = new List<EntrySearchResultDto>();
|
||||
foreach (var filePath in Directory.GetFiles(request.DataDirectory, "*.md")
|
||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal))
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var fileStem = Path.GetFileNameWithoutExtension(filePath);
|
||||
var rawContent = File.ReadAllText(filePath);
|
||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||
|
||||
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 ? entry.GetSection(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(new EntrySearchResultDto(entry.Date, fileName, entry.RawContent));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>(results);
|
||||
}
|
||||
|
||||
private static HashSet<string> NormalizeSet(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
return [];
|
||||
|
||||
var set = new HashSet<string>(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.");
|
||||
}
|
||||
}
|
||||
@ -37,12 +37,16 @@ public class FragmentService : IFragmentService
|
||||
public async Task<bool> UpdateAsync(Guid id, UpdateFragmentDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
if (dto.Type != null)
|
||||
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");
|
||||
|
||||
return await _repo.UpdateAsync(id, dto.Type, dto.Description, dto.Tags, dto.Time);
|
||||
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 await _repo.UpdateAsync(id, type, description, tags, dto.Time);
|
||||
}
|
||||
|
||||
public Task<bool> RemoveAsync(Guid id) => _repo.RemoveAsync(id);
|
||||
|
||||
12
Journal.Core/Services/IAiService.cs
Normal file
12
Journal.Core/Services/IAiService.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IAiService
|
||||
{
|
||||
Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default);
|
||||
Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default);
|
||||
Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default);
|
||||
Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default);
|
||||
}
|
||||
8
Journal.Core/Services/IEntrySearchService.cs
Normal file
8
Journal.Core/Services/IEntrySearchService.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IEntrySearchService
|
||||
{
|
||||
Task<IReadOnlyList<EntrySearchResultDto>> SearchEntriesAsync(EntrySearchRequestDto request);
|
||||
}
|
||||
8
Journal.Core/Services/IJournalConfigService.cs
Normal file
8
Journal.Core/Services/IJournalConfigService.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IJournalConfigService
|
||||
{
|
||||
JournalConfig Current { get; }
|
||||
}
|
||||
29
Journal.Core/Services/IJournalDatabaseService.cs
Normal file
29
Journal.Core/Services/IJournalDatabaseService.cs
Normal file
@ -0,0 +1,29 @@
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IJournalDatabaseService
|
||||
{
|
||||
string GetDatabasePath(string? dataDirectory = null);
|
||||
byte[] DeriveDatabaseKey(string password);
|
||||
string BuildPragmaKeyStatement(string password);
|
||||
IReadOnlyDictionary<string, string> GetSchemaStatements();
|
||||
string WriteSchemaBootstrap(string? dataDirectory = null);
|
||||
JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null);
|
||||
JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null);
|
||||
}
|
||||
|
||||
public sealed record JournalDatabaseStatus(
|
||||
string DatabasePath,
|
||||
int KeyLengthBytes,
|
||||
int Iterations,
|
||||
string KeyDerivation,
|
||||
IReadOnlyList<string> SchemaTables,
|
||||
string SchemaBootstrapPath,
|
||||
bool RuntimeReady,
|
||||
string RuntimeMessage);
|
||||
|
||||
public sealed record JournalDatabaseHydrationResult(
|
||||
string DatabasePath,
|
||||
string SchemaBootstrapPath,
|
||||
int EntryFilesProcessed,
|
||||
bool RuntimeReady,
|
||||
string Message);
|
||||
9
Journal.Core/Services/ISpeechBridgeService.cs
Normal file
9
Journal.Core/Services/ISpeechBridgeService.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface ISpeechBridgeService
|
||||
{
|
||||
Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default);
|
||||
Task<SpeechTranscribeResultDto> TranscribeAsync(SpeechTranscribeRequestDto request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
8
Journal.Core/Services/IVaultCryptoService.cs
Normal file
8
Journal.Core/Services/IVaultCryptoService.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IVaultCryptoService
|
||||
{
|
||||
byte[] DeriveKey(string password, byte[] salt);
|
||||
byte[] EncryptData(byte[] data, string password);
|
||||
byte[] DecryptData(byte[] encryptedData, string password);
|
||||
}
|
||||
10
Journal.Core/Services/IVaultStorageService.cs
Normal file
10
Journal.Core/Services/IVaultStorageService.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IVaultStorageService
|
||||
{
|
||||
string GetMonthlyVaultFileName(DateTime date);
|
||||
bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory);
|
||||
bool SaveCurrentMonthVault(string password, string vaultDirectory, string dataDirectory, DateTime now);
|
||||
void RebuildAllVaults(string password, string vaultDirectory, string dataDirectory);
|
||||
void ClearDataDirectory(string dataDirectory);
|
||||
}
|
||||
107
Journal.Core/Services/JournalConfigService.cs
Normal file
107
Journal.Core/Services/JournalConfigService.cs
Normal file
@ -0,0 +1,107 @@
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class JournalConfigService : IJournalConfigService
|
||||
{
|
||||
public JournalConfig Current { get; } = BuildConfig();
|
||||
|
||||
private static JournalConfig BuildConfig()
|
||||
{
|
||||
var projectRoot = ResolveProjectRoot();
|
||||
var appDirectory = ResolvePath("JOURNAL_APP_DIR", Path.Combine(projectRoot, "journal"));
|
||||
|
||||
var dataDirectory = ResolvePath("JOURNAL_DATA_DIR", Path.Combine(appDirectory, "data"));
|
||||
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") ?? "none").Trim().ToLowerInvariant();
|
||||
if (aiProvider is not ("none" or "python-sidecar"))
|
||||
aiProvider = "none";
|
||||
|
||||
var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE");
|
||||
if (string.IsNullOrWhiteSpace(pythonExecutable))
|
||||
pythonExecutable = "python";
|
||||
|
||||
var defaultAiSidecarPath = Path.Combine(projectRoot, "journal", "ai", "sidecar.py");
|
||||
var pythonAiSidecarPath = ResolvePath("JOURNAL_AI_SIDECAR_PATH", defaultAiSidecarPath);
|
||||
var aiSidecarTimeoutMs = ParseInt("JOURNAL_AI_TIMEOUT_MS", 45000);
|
||||
|
||||
return new JournalConfig(
|
||||
ProjectRoot: projectRoot,
|
||||
AppDirectory: appDirectory,
|
||||
DataDirectory: dataDirectory,
|
||||
VaultDirectory: vaultDirectory,
|
||||
LogDirectory: logDirectory,
|
||||
PidFile: pidFile,
|
||||
ServerControlFile: serverControlFile,
|
||||
DatabaseFilename: Environment.GetEnvironmentVariable("JOURNAL_DATABASE_FILENAME") ?? "journal_cache.db",
|
||||
MonthlyVaultFormat: Environment.GetEnvironmentVariable("JOURNAL_MONTHLY_VAULT_FORMAT") ?? "%Y-%m.vault",
|
||||
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,
|
||||
PythonExecutable: pythonExecutable,
|
||||
PythonAiSidecarPath: pythonAiSidecarPath,
|
||||
AiSidecarTimeoutMs: aiSidecarTimeoutMs);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
233
Journal.Core/Services/JournalDatabaseService.cs
Normal file
233
Journal.Core/Services/JournalDatabaseService.cs
Normal file
@ -0,0 +1,233 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class JournalDatabaseService : 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 object SqliteInitLock = new();
|
||||
private static bool _sqliteInitialized;
|
||||
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
||||
["entries", "sections", "fragments", "tags", "fragment_tags"];
|
||||
|
||||
private readonly IJournalConfigService _config;
|
||||
|
||||
public JournalDatabaseService(IJournalConfigService config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public string GetDatabasePath(string? dataDirectory = null)
|
||||
{
|
||||
var directory = string.IsNullOrWhiteSpace(dataDirectory)
|
||||
? _config.Current.DataDirectory
|
||||
: dataDirectory;
|
||||
|
||||
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<string, string> GetSchemaStatements()
|
||||
{
|
||||
return new Dictionary<string, string>(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,
|
||||
entry_id INTEGER NOT NULL,
|
||||
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)
|
||||
);
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
public string WriteSchemaBootstrap(string? dataDirectory = null)
|
||||
{
|
||||
var directory = string.IsNullOrWhiteSpace(dataDirectory)
|
||||
? _config.Current.DataDirectory
|
||||
: dataDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var bootstrapPath = Path.GetFullPath(Path.Combine(directory, "journal_schema.sql"));
|
||||
var statements = GetSchemaStatements()
|
||||
.Select(pair => $"-- {pair.Key}\n{pair.Value.Trim()}")
|
||||
.ToArray();
|
||||
var content = string.Join("\n\n", statements) + "\n";
|
||||
File.WriteAllText(bootstrapPath, content);
|
||||
return bootstrapPath;
|
||||
}
|
||||
|
||||
public JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null)
|
||||
{
|
||||
var tables = GetSchemaStatements().Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
var bootstrapPath = WriteSchemaBootstrap(dataDirectory);
|
||||
var runtime = ProbeRuntime(password, dataDirectory);
|
||||
return new JournalDatabaseStatus(
|
||||
DatabasePath: GetDatabasePath(dataDirectory),
|
||||
KeyLengthBytes: DeriveDatabaseKey(password).Length,
|
||||
Iterations: Iterations,
|
||||
KeyDerivation: "PBKDF2-HMAC-SHA256",
|
||||
SchemaTables: tables,
|
||||
SchemaBootstrapPath: bootstrapPath,
|
||||
RuntimeReady: runtime.Ready,
|
||||
RuntimeMessage: runtime.Message);
|
||||
}
|
||||
|
||||
public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null)
|
||||
{
|
||||
var directory = string.IsNullOrWhiteSpace(dataDirectory)
|
||||
? _config.Current.DataDirectory
|
||||
: dataDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
using var connection = OpenEncryptedConnection(password, directory);
|
||||
CreateSchema(connection);
|
||||
var runtimeReady = HasRequiredTables(connection);
|
||||
|
||||
var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length;
|
||||
var schemaPath = WriteSchemaBootstrap(directory);
|
||||
|
||||
return new JournalDatabaseHydrationResult(
|
||||
DatabasePath: GetDatabasePath(directory),
|
||||
SchemaBootstrapPath: schemaPath,
|
||||
EntryFilesProcessed: entryFilesProcessed,
|
||||
RuntimeReady: runtimeReady,
|
||||
Message: runtimeReady
|
||||
? "Workspace hydration completed with SQLCipher runtime schema validation."
|
||||
: "Workspace hydration completed, but required schema tables were not found.");
|
||||
}
|
||||
|
||||
private static void EnsureSqliteInitialized()
|
||||
{
|
||||
if (_sqliteInitialized)
|
||||
return;
|
||||
|
||||
lock (SqliteInitLock)
|
||||
{
|
||||
if (_sqliteInitialized)
|
||||
return;
|
||||
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
_sqliteInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
||||
|
||||
EnsureSqliteInitialized();
|
||||
|
||||
var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};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;
|
||||
}
|
||||
|
||||
private void CreateSchema(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<string>(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, string? dataDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = OpenEncryptedConnection(password, dataDirectory);
|
||||
CreateSchema(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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
175
Journal.Core/Services/JournalParser.cs
Normal file
175
Journal.Core/Services/JournalParser.cs
Normal file
@ -0,0 +1,175 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
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<string, ParsedSection> ParseSections(string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var parsedSections = new Dictionary<string, ParsedSection>();
|
||||
string? currentSectionTitle = null;
|
||||
var currentSectionContent = new List<string>();
|
||||
var currentSectionCheckboxes = new Dictionary<string, bool>();
|
||||
|
||||
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<Fragment> ParseFragments(string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var fragments = new List<Fragment>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
73
Journal.Core/Services/LogRedactor.cs
Normal file
73
Journal.Core/Services/LogRedactor.cs
Normal file
@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public static class LogRedactor
|
||||
{
|
||||
private static readonly HashSet<string> 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<string, object?> RedactObject(JsonElement element)
|
||||
{
|
||||
var output = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
output[property.Name] = RedactElement(property.Value, property.Name);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static List<object?> RedactArray(JsonElement element)
|
||||
{
|
||||
var output = new List<object?>();
|
||||
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)";
|
||||
}
|
||||
}
|
||||
190
Journal.Core/Services/PythonSidecarAiService.cs
Normal file
190
Journal.Core/Services/PythonSidecarAiService.cs
Normal file
@ -0,0 +1,190 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarAiService : IAiService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
|
||||
public PythonSidecarAiService(JournalConfig config)
|
||||
{
|
||||
_config = config;
|
||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
||||
throw new ArgumentException("Python AI sidecar path is required.");
|
||||
if (!File.Exists(_config.PythonAiSidecarPath))
|
||||
throw new FileNotFoundException($"Python AI sidecar not found: {_config.PythonAiSidecarPath}");
|
||||
}
|
||||
|
||||
public async Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = await SendAsync("health", payload: new { }, cancellationToken);
|
||||
if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object)
|
||||
return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok");
|
||||
|
||||
var provider = payload.TryGetProperty("provider", out var providerNode)
|
||||
? providerNode.GetString() ?? "python-sidecar"
|
||||
: "python-sidecar";
|
||||
var message = payload.TryGetProperty("message", out var messageNode)
|
||||
? messageNode.GetString() ?? "ok"
|
||||
: "ok";
|
||||
var healthy = !payload.TryGetProperty("healthy", out var healthyNode) ||
|
||||
healthyNode.ValueKind is JsonValueKind.True ||
|
||||
(healthyNode.ValueKind is JsonValueKind.False ? false : true);
|
||||
return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message);
|
||||
}
|
||||
|
||||
public async Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
throw new ArgumentException("Entry content is required.", nameof(content));
|
||||
|
||||
var data = await SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
public async Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default)
|
||||
{
|
||||
entries ??= [];
|
||||
var data = await SendAsync("summarize_all", new { entries }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
public async Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
throw new ArgumentException("Prompt is required.", nameof(prompt));
|
||||
|
||||
var data = await SendAsync("chat", new { prompt }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
throw new ArgumentException("Content is required.", nameof(content));
|
||||
|
||||
var data = await SendAsync("embed", new { content }, cancellationToken);
|
||||
if (data is null || data.Value.ValueKind == JsonValueKind.Null)
|
||||
return [];
|
||||
|
||||
if (data.Value.ValueKind != JsonValueKind.Array)
|
||||
throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array.");
|
||||
|
||||
var values = new List<double>();
|
||||
foreach (var item in data.Value.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Number)
|
||||
throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value.");
|
||||
values.Add(item.GetDouble());
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions);
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _config.PythonExecutable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = _config.ProjectRoot
|
||||
};
|
||||
process.StartInfo.ArgumentList.Add(_config.PythonAiSidecarPath);
|
||||
|
||||
if (!process.Start())
|
||||
throw new InvalidOperationException("Failed to start Python AI sidecar process.");
|
||||
|
||||
await process.StandardInput.WriteLineAsync(request);
|
||||
process.StandardInput.Close();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_config.AiSidecarTimeoutMs);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
throw new TimeoutException($"Python AI sidecar timed out after {_config.AiSidecarTimeoutMs} ms.");
|
||||
}
|
||||
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
var line = LastJsonLine(stdout);
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
throw new InvalidOperationException($"Python AI sidecar returned no JSON response. stderr: {stderr}".Trim());
|
||||
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(line);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid JSON from Python AI sidecar: {line}", ex);
|
||||
}
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("ok", out var okNode) || okNode.ValueKind != JsonValueKind.True && okNode.ValueKind != JsonValueKind.False)
|
||||
throw new InvalidOperationException("Python AI sidecar response missing boolean 'ok' field.");
|
||||
|
||||
if (!okNode.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errorNode)
|
||||
? errorNode.GetString() ?? "Unknown sidecar error."
|
||||
: "Unknown sidecar error.";
|
||||
throw new InvalidOperationException(error);
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataNode))
|
||||
return null;
|
||||
|
||||
return dataNode.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
private static string LastJsonLine(string text)
|
||||
{
|
||||
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
for (var i = lines.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
|
||||
return line;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors while handling timeout/failure path.
|
||||
}
|
||||
}
|
||||
}
|
||||
184
Journal.Core/Services/PythonSidecarSpeechService.cs
Normal file
184
Journal.Core/Services/PythonSidecarSpeechService.cs
Normal file
@ -0,0 +1,184 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
|
||||
public PythonSidecarSpeechService(JournalConfig config)
|
||||
{
|
||||
_config = config;
|
||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
||||
throw new ArgumentException("Python sidecar path is required.");
|
||||
if (!File.Exists(_config.PythonAiSidecarPath))
|
||||
throw new FileNotFoundException($"Python sidecar not found: {_config.PythonAiSidecarPath}");
|
||||
}
|
||||
|
||||
public async Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = await SendAsync("speech.devices.list", new { }, cancellationToken);
|
||||
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
||||
return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar.");
|
||||
|
||||
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
||||
? warningNode.GetString()
|
||||
: null;
|
||||
|
||||
var devices = new List<SpeechDeviceDto>();
|
||||
if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var device in devicesNode.EnumerateArray())
|
||||
{
|
||||
if (device.ValueKind != JsonValueKind.Object)
|
||||
continue;
|
||||
|
||||
var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number
|
||||
? indexNode.GetInt32()
|
||||
: -1;
|
||||
var name = device.TryGetProperty("name", out var nameNode)
|
||||
? nameNode.GetString() ?? ""
|
||||
: "";
|
||||
devices.Add(new SpeechDeviceDto(index, name));
|
||||
}
|
||||
}
|
||||
|
||||
return new SpeechDevicesResultDto(devices, warning);
|
||||
}
|
||||
|
||||
public async Task<SpeechTranscribeResultDto> TranscribeAsync(
|
||||
SpeechTranscribeRequestDto request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var data = await SendAsync("speech.transcribe", new
|
||||
{
|
||||
audio_base64 = request.AudioBase64,
|
||||
engine = request.Engine,
|
||||
whisper_model = request.WhisperModel,
|
||||
text = request.Text,
|
||||
simulate_delay_ms = request.SimulateDelayMs
|
||||
}, cancellationToken);
|
||||
|
||||
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
||||
throw new InvalidOperationException("Python sidecar speech response must be a JSON object.");
|
||||
|
||||
var text = data.Value.TryGetProperty("text", out var textNode)
|
||||
? textNode.GetString() ?? ""
|
||||
: "";
|
||||
var engine = data.Value.TryGetProperty("engine", out var engineNode)
|
||||
? engineNode.GetString() ?? (request.Engine ?? "whisper")
|
||||
: (request.Engine ?? "whisper");
|
||||
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
||||
? warningNode.GetString()
|
||||
: null;
|
||||
return new SpeechTranscribeResultDto(text, engine, warning);
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> SendAsync(string action, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions);
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _config.PythonExecutable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = _config.ProjectRoot
|
||||
};
|
||||
process.StartInfo.ArgumentList.Add(_config.PythonAiSidecarPath);
|
||||
|
||||
if (!process.Start())
|
||||
throw new InvalidOperationException("Failed to start Python sidecar process.");
|
||||
|
||||
await process.StandardInput.WriteLineAsync(request);
|
||||
process.StandardInput.Close();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_config.AiSidecarTimeoutMs);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
throw new TimeoutException($"Python sidecar timed out after {_config.AiSidecarTimeoutMs} ms.");
|
||||
}
|
||||
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
var line = LastJsonLine(stdout);
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
throw new InvalidOperationException($"Python sidecar returned no JSON response. stderr: {stderr}".Trim());
|
||||
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(line);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid JSON from Python sidecar: {line}", ex);
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("ok", out var okNode) || okNode.ValueKind != JsonValueKind.True && okNode.ValueKind != JsonValueKind.False)
|
||||
throw new InvalidOperationException("Python sidecar response missing boolean 'ok' field.");
|
||||
|
||||
if (!okNode.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errorNode)
|
||||
? errorNode.GetString() ?? "Unknown sidecar error."
|
||||
: "Unknown sidecar error.";
|
||||
throw new InvalidOperationException(error);
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataNode))
|
||||
return null;
|
||||
|
||||
return dataNode.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
private static string LastJsonLine(string text)
|
||||
{
|
||||
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
for (var i = lines.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
|
||||
return line;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore timeout cleanup failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
385
Journal.Core/Services/SidecarCli.cs
Normal file
385
Journal.Core/Services/SidecarCli.cs
Normal file
@ -0,0 +1,385 @@
|
||||
using System.Text;
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class SidecarCli
|
||||
{
|
||||
private readonly IVaultStorageService _vaultStorage;
|
||||
private readonly IEntrySearchService _entrySearch;
|
||||
private readonly IJournalConfigService _config;
|
||||
|
||||
public SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
|
||||
{
|
||||
_vaultStorage = vaultStorage;
|
||||
_entrySearch = entrySearch;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<int> 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).ToArray());
|
||||
if (string.Equals(args[0], "search", StringComparison.OrdinalIgnoreCase))
|
||||
return RunSearchCommand(args.Skip(1).ToArray());
|
||||
|
||||
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).ToArray(), 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, dataDirectory) = ResolveDirectories(options.VaultDirectory, options.DataDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
if (action == "load")
|
||||
{
|
||||
var ok = _vaultStorage.LoadAllVaults(password, vaultDirectory, dataDirectory);
|
||||
if (!ok)
|
||||
{
|
||||
Console.Error.WriteLine("Incorrect password.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Vault loaded. Decrypted files are in {dataDirectory}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
_vaultStorage.RebuildAllVaults(password, vaultDirectory, dataDirectory);
|
||||
Console.WriteLine($"Vault saved from decrypted files in {dataDirectory}");
|
||||
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 (_, dataDirectory) = ResolveDirectories(vaultOverride: null, options.DataDirectory);
|
||||
if (!Directory.Exists(dataDirectory) || Directory.GetFiles(dataDirectory, "*.md").Length == 0)
|
||||
{
|
||||
Console.WriteLine("No decrypted journal entries found. Please load the vault first: journal vault load");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new EntrySearchRequestDto(
|
||||
DataDirectory: dataDirectory,
|
||||
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.Date} ---");
|
||||
Console.WriteLine(result.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;
|
||||
case "--data-dir":
|
||||
parsed.DataDirectory = 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("-", StringComparison.Ordinal))
|
||||
{
|
||||
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 "--data-dir":
|
||||
parsed.DataDirectory = value;
|
||||
break;
|
||||
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 VaultDirectory, string DataDirectory) ResolveDirectories(string? vaultOverride, string? dataOverride)
|
||||
{
|
||||
var envVault = Environment.GetEnvironmentVariable("JOURNAL_VAULT_DIR");
|
||||
var envData = Environment.GetEnvironmentVariable("JOURNAL_DATA_DIR");
|
||||
var defaults = _config.Current;
|
||||
|
||||
var vault = FirstNonEmpty(vaultOverride, envVault) ?? defaults.VaultDirectory;
|
||||
var data = FirstNonEmpty(dataOverride, envData) ?? defaults.DataDirectory;
|
||||
return (Path.GetFullPath(vault), Path.GetFullPath(data));
|
||||
}
|
||||
|
||||
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 <value>] [--vault-dir <path>] [--data-dir <path>]");
|
||||
Console.WriteLine(" Journal.Sidecar vault save [--password <value>] [--vault-dir <path>] [--data-dir <path>]");
|
||||
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>] [--data-dir <path>]");
|
||||
}
|
||||
|
||||
private static void PrintVaultUsage()
|
||||
{
|
||||
Console.WriteLine("Vault usage:");
|
||||
Console.WriteLine(" Journal.Sidecar vault load [--password <value>] [--vault-dir <path>] [--data-dir <path>]");
|
||||
Console.WriteLine(" Journal.Sidecar vault save [--password <value>] [--vault-dir <path>] [--data-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>] [--data-dir <path>]");
|
||||
}
|
||||
|
||||
private sealed class VaultOptions
|
||||
{
|
||||
public string? Password { get; set; }
|
||||
public string? VaultDirectory { get; set; }
|
||||
public string? DataDirectory { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SearchOptions
|
||||
{
|
||||
public string? Query { get; set; }
|
||||
public string? DataDirectory { 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; } = [];
|
||||
}
|
||||
}
|
||||
83
Journal.Core/Services/VaultCryptoService.cs
Normal file
83
Journal.Core/Services/VaultCryptoService.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
276
Journal.Core/Services/VaultStorageService.cs
Normal file
276
Journal.Core/Services/VaultStorageService.cs
Normal file
@ -0,0 +1,276 @@
|
||||
using System.IO.Compression;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public class VaultStorageService : IVaultStorageService
|
||||
{
|
||||
private readonly IVaultCryptoService _crypto;
|
||||
private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal);
|
||||
private readonly object _vaultIoLock = new();
|
||||
|
||||
public VaultStorageService(IVaultCryptoService crypto) => _crypto = crypto;
|
||||
|
||||
public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault";
|
||||
|
||||
public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory)
|
||||
{
|
||||
EnsureRequiredArguments(password, vaultDirectory, dataDirectory);
|
||||
|
||||
lock (_vaultIoLock)
|
||||
{
|
||||
_monthFingerprintCache.Clear();
|
||||
PrepareDataDirectory(dataDirectory);
|
||||
|
||||
if (!Directory.Exists(vaultDirectory))
|
||||
return true;
|
||||
|
||||
var vaultFiles = Directory.GetFiles(vaultDirectory, "*.vault")
|
||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (vaultFiles.Length == 0)
|
||||
return true;
|
||||
|
||||
var anyDecrypted = false;
|
||||
var anyVaultFiles = false;
|
||||
foreach (var vaultFile in vaultFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(vaultFile);
|
||||
if (string.Equals(fileName, "_init_vault.vault", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(vaultFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Legacy file cleanup should never block loading.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
anyVaultFiles = true;
|
||||
try
|
||||
{
|
||||
var encrypted = File.ReadAllBytes(vaultFile);
|
||||
var decryptedZip = _crypto.DecryptData(encrypted, password);
|
||||
ExtractZipContent(decryptedZip, dataDirectory);
|
||||
anyDecrypted = true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Wrong password for this vault file; continue trying others.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-password vault read/decrypt/extract error; continue loading others.
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyDecrypted && anyVaultFiles)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SaveCurrentMonthVault(string password, string vaultDirectory, string dataDirectory, DateTime now)
|
||||
{
|
||||
EnsureRequiredArguments(password, vaultDirectory, dataDirectory);
|
||||
|
||||
lock (_vaultIoLock)
|
||||
{
|
||||
Directory.CreateDirectory(vaultDirectory);
|
||||
if (!Directory.Exists(dataDirectory))
|
||||
return false;
|
||||
|
||||
var monthKey = now.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
var filesInMonth = Directory.GetFiles(dataDirectory, "*.md")
|
||||
.Where(path => Path.GetFileNameWithoutExtension(path).StartsWith(monthKey, StringComparison.Ordinal))
|
||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (filesInMonth.Count == 0)
|
||||
return false;
|
||||
|
||||
var currentFingerprint = ComputeMonthFingerprint(filesInMonth);
|
||||
if (_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) &&
|
||||
string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void RebuildAllVaults(string password, string vaultDirectory, string dataDirectory)
|
||||
{
|
||||
EnsureRequiredArguments(password, vaultDirectory, dataDirectory);
|
||||
|
||||
lock (_vaultIoLock)
|
||||
{
|
||||
Directory.CreateDirectory(vaultDirectory);
|
||||
if (!Directory.Exists(dataDirectory))
|
||||
return;
|
||||
|
||||
var monthlyFiles = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var filePath in Directory.GetFiles(dataDirectory, "*.md"))
|
||||
{
|
||||
var stem = Path.GetFileNameWithoutExtension(filePath);
|
||||
if (!DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fileDate))
|
||||
continue;
|
||||
|
||||
var monthKey = fileDate.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
if (!monthlyFiles.TryGetValue(monthKey, out var files))
|
||||
{
|
||||
files = [];
|
||||
monthlyFiles[monthKey] = files;
|
||||
}
|
||||
|
||||
files.Add(filePath);
|
||||
}
|
||||
|
||||
foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearDataDirectory(string dataDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataDirectory))
|
||||
throw new ArgumentException("Data directory is required.", nameof(dataDirectory));
|
||||
|
||||
lock (_vaultIoLock)
|
||||
{
|
||||
PrepareDataDirectory(dataDirectory);
|
||||
_monthFingerprintCache.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrepareDataDirectory(string dataDirectory)
|
||||
{
|
||||
DeleteDirectoryWithRetries(dataDirectory);
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Final attempt should throw with the underlying exception if deletion still fails.
|
||||
Directory.Delete(dataDirectory, recursive: true);
|
||||
}
|
||||
|
||||
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 void SaveMonth(string password, string monthKey, List<string> filesInMonth, string vaultDirectory)
|
||||
{
|
||||
var monthDate = DateTime.ParseExact(monthKey, "yyyy-MM", CultureInfo.InvariantCulture);
|
||||
var monthlyVaultPath = Path.Combine(vaultDirectory, GetMonthlyVaultFileName(monthDate));
|
||||
|
||||
var zipBytes = CreateMonthlyArchive(filesInMonth);
|
||||
var encryptedPayload = _crypto.EncryptData(zipBytes, password);
|
||||
File.WriteAllBytes(monthlyVaultPath, encryptedPayload);
|
||||
|
||||
_monthFingerprintCache[monthKey] = ComputeMonthFingerprint(filesInMonth);
|
||||
}
|
||||
|
||||
private static byte[] CreateMonthlyArchive(List<string> filesInMonth)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var filePath in filesInMonth.OrderBy(Path.GetFileName, StringComparer.Ordinal))
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
continue;
|
||||
|
||||
var entry = archive.CreateEntry(fileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
using var sourceStream = File.OpenRead(filePath);
|
||||
sourceStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
private static string ComputeMonthFingerprint(List<string> files)
|
||||
{
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
foreach (var filePath in files.OrderBy(Path.GetFileName, StringComparer.Ordinal))
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists)
|
||||
continue;
|
||||
|
||||
AppendUtf8(hash, fileInfo.Name);
|
||||
AppendAscii(hash, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture));
|
||||
AppendAscii(hash, fileInfo.Length.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void AppendUtf8(IncrementalHash hash, string value) => hash.AppendData(Encoding.UTF8.GetBytes(value));
|
||||
private static void AppendAscii(IncrementalHash hash, string value) => hash.AppendData(Encoding.ASCII.GetBytes(value));
|
||||
|
||||
private static void ExtractZipContent(byte[] zipBytes, string dataDirectory)
|
||||
{
|
||||
using var stream = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
|
||||
var dataRoot = Path.GetFullPath(dataDirectory);
|
||||
if (!dataRoot.EndsWith(Path.DirectorySeparatorChar))
|
||||
dataRoot += Path.DirectorySeparatorChar;
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
continue;
|
||||
|
||||
var destinationPath = Path.GetFullPath(Path.Combine(dataDirectory, entry.FullName));
|
||||
if (!destinationPath.StartsWith(dataRoot, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Zip entry path escapes target data directory.");
|
||||
|
||||
var destinationDir = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(destinationDir))
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
entry.ExtractToFile(destinationPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Journal.Core;
|
||||
using Journal.Core.Services;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddFragmentServices();
|
||||
@ -7,4 +8,6 @@ services.AddSingleton<Entry>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var entry = provider.GetRequiredService<Entry>();
|
||||
await entry.RunAsync();
|
||||
var cli = provider.GetRequiredService<SidecarCli>();
|
||||
var exitCode = await cli.RunAsync(args, entry);
|
||||
Environment.ExitCode = exitCode;
|
||||
|
||||
50
Journal.SmokeTests/Fixtures/transport_cases.json
Normal file
50
Journal.SmokeTests/Fixtures/transport_cases.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
20
Journal.SmokeTests/Journal.SmokeTests.csproj
Normal file
20
Journal.SmokeTests/Journal.SmokeTests.csproj
Normal file
@ -0,0 +1,20 @@
|
||||
<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>
|
||||
<None Update="Fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
2129
Journal.SmokeTests/Program.cs
Normal file
2129
Journal.SmokeTests/Program.cs
Normal file
File diff suppressed because it is too large
Load Diff
112
README.md
112
README.md
@ -9,7 +9,10 @@ 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
|
||||
│ │ ├── Command.cs Stdin command shape for sidecar protocol
|
||||
│ │ ├── ParsedSection.cs Parsed section model for entry parity work
|
||||
│ │ ├── SectionTitles.cs Canonical section title list (Python parity)
|
||||
│ │ └── JournalEntry.cs Entry domain (`date/raw_content/sections/fragments` + merge + markdown reconstruction)
|
||||
│ ├── Dtos/
|
||||
│ │ └── FragmentDtos.cs Immutable records for API boundary
|
||||
│ │ ├── FragmentDto Read (what goes out)
|
||||
@ -17,10 +20,24 @@ backend/
|
||||
│ │ └── UpdateFragmentDto Update (partial, all fields optional)
|
||||
│ ├── Repositories/
|
||||
│ │ ├── IFragmentRepository.cs Interface (data access contract)
|
||||
│ │ └── InMemoryFragmentRepository.cs In-memory implementation
|
||||
│ │ ├── InMemoryFragmentRepository.cs In-memory implementation (tests/dev)
|
||||
│ │ └── FileFragmentRepository.cs File-backed implementation (default)
|
||||
│ ├── Services/
|
||||
│ │ ├── IFragmentService.cs Interface (business logic contract)
|
||||
│ │ └── FragmentService.cs Validates, calls repo, maps to DTOs
|
||||
│ │ ├── FragmentService.cs Validates, calls repo, maps to DTOs
|
||||
│ │ ├── IEntrySearchService.cs Entry search contract (content parity)
|
||||
│ │ ├── EntrySearchService.cs Searches decrypted `.md` entries by raw content query
|
||||
│ │ ├── IJournalConfigService.cs Config contract for path/vault/AI/speech settings parity
|
||||
│ │ ├── JournalConfigService.cs Env/default-backed config surface aligned with Python keys
|
||||
│ │ ├── IAiService.cs AI bridge contract (optional provider)
|
||||
│ │ ├── DisabledAiService.cs No-op AI provider for deterministic disabled mode
|
||||
│ │ ├── PythonSidecarAiService.cs Local Python sidecar adapter (stdin/stdout JSON)
|
||||
│ │ ├── SidecarCli.cs CLI runner (`vault` + `search`) used by Sidecar host
|
||||
│ │ ├── JournalParser.cs Date + section + checkbox + fragment parser slices (Phase 2)
|
||||
│ │ ├── IVaultCryptoService.cs Vault crypto contract
|
||||
│ │ ├── VaultCryptoService.cs AES-256-GCM + PBKDF2 compatibility layer
|
||||
│ │ ├── IVaultStorageService.cs Vault load/workflow contract
|
||||
│ │ └── VaultStorageService.cs Monthly naming + load/decrypt/extract workflow
|
||||
│ ├── Entry.cs Command dispatcher (stdin/stdout)
|
||||
│ ├── ServiceCollectionExtensions.cs DI registration helper
|
||||
│ └── Journal.Core.csproj
|
||||
@ -48,7 +65,7 @@ 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.
|
||||
- **Repositories** — Where data lives. Current default is file-backed; can evolve to SQLite/EF Core 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.
|
||||
|
||||
@ -56,6 +73,7 @@ API (HTTP/JSON) ─────────┘
|
||||
|
||||
- **Journal.Core** — `Microsoft.Extensions.DependencyInjection.Abstractions` (interface-only, lightweight)
|
||||
- **Journal.Sidecar** — `Microsoft.Extensions.DependencyInjection` (full container implementation) + references `Journal.Core`
|
||||
- **Journal.Api** — `Microsoft.AspNetCore.OpenApi` + ASP.NET shared framework
|
||||
|
||||
## Building
|
||||
|
||||
@ -91,6 +109,52 @@ dotnet publish backend\Journal.Sidecar\Journal.Sidecar.csproj -c Release -r win-
|
||||
## Sidecar Protocol
|
||||
|
||||
The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, one JSON line out.
|
||||
When run with no command-line args, this protocol mode is used by default.
|
||||
|
||||
## Sidecar CLI
|
||||
|
||||
`Journal.Sidecar` also supports direct vault and search CLI commands:
|
||||
|
||||
```powershell
|
||||
# Load vaults into decrypted data workspace
|
||||
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault load
|
||||
|
||||
# Save (rebuild) monthly vaults from decrypted markdown files
|
||||
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- vault save
|
||||
|
||||
# Search entries (query + filters)
|
||||
dotnet run --project Journal.Sidecar/Journal.Sidecar.csproj -- search "common text" --tag stress --type !TRIGGER --start-date 2026-02-01 --end-date 2026-02-28 --section Summary --checked "med taken"
|
||||
```
|
||||
|
||||
Password prompt behavior:
|
||||
- If `--password` is omitted, CLI prompts with `Vault password:` (hidden input in terminal mode).
|
||||
- For automation/non-interactive use, pass `--password <value>`.
|
||||
|
||||
Optional path overrides:
|
||||
- `--vault-dir <path>`
|
||||
- `--data-dir <path>`
|
||||
- Env fallback: `JOURNAL_VAULT_DIR`, `JOURNAL_DATA_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)
|
||||
- `--data-dir <path>` (optional override)
|
||||
|
||||
## Config Keys (Parity Surface)
|
||||
|
||||
`JournalConfigService` exposes and normalizes key settings expected from Python config:
|
||||
|
||||
- Paths: `JOURNAL_PROJECT_ROOT`, `JOURNAL_APP_DIR`, `JOURNAL_DATA_DIR`, `JOURNAL_VAULT_DIR`, `JOURNAL_LOG_DIR`, `JOURNAL_PID_FILE`, `JOURNAL_SERVER_CONTROL_FILE`
|
||||
- Vault format: `JOURNAL_MONTHLY_VAULT_FORMAT` (default `%Y-%m.vault`)
|
||||
- AI endpoints/models: `CLOUDAI_API_KEY`, `CLOUDAI_API_URL`, `LLAMA_CPP_URL`, `LLAMA_CPP_MODEL`, `LLAMA_CPP_TIMEOUT`, `EMBEDDING_API_URL`, `EMBEDDING_MODEL_NAME`, `MODEL_CONTEXT_TOKENS`, `CHUNK_TOKEN_BUDGET`
|
||||
- AI bridge mode: `JOURNAL_AI_PROVIDER` (`none` or `python-sidecar`), `JOURNAL_PYTHON_EXE`, `JOURNAL_AI_SIDECAR_PATH`, `JOURNAL_AI_TIMEOUT_MS`
|
||||
- Speech/NLP: `MICROPHONE_DEVICE_INDEX`, `SPEECH_RECOGNITION_ENGINE`, `WHISPER_MODEL_SIZE`, `JOURNAL_NLP_BACKEND`
|
||||
|
||||
### Command Format
|
||||
|
||||
@ -120,6 +184,24 @@ The sidecar communicates over stdin/stdout using JSON lines. One JSON line in, o
|
||||
| `fragments.update` | Update a fragment | `id`, `payload` (UpdateFragmentDto) |
|
||||
| `fragments.delete` | Delete a fragment | `id` |
|
||||
| `fragments.search` | Search by type/tag | `type` and/or `tag` |
|
||||
| `entries.list` | List decrypted markdown entries in a data directory | optional `payload.dataDirectory` |
|
||||
| `entries.load` | Load one entry file and return parsed metadata + raw content | `payload.filePath` |
|
||||
| `entries.save` | Save/merge entry content to file (fragment append or full merge path) | `payload.content`, optional `payload.filePath`, `payload.mode` |
|
||||
| `db.status` | Return DB key/schema compatibility status snapshot | `payload.password`, optional `payload.dataDirectory` |
|
||||
| `db.initialize_schema` | Write SQL schema bootstrap (`journal_schema.sql`) for parity tables | optional `payload.dataDirectory` |
|
||||
| `db.hydrate_workspace` | Perform C# DB hydration step for decrypted workspace (schema bootstrap + metadata) | `payload.password`, optional `payload.dataDirectory` |
|
||||
| `config.get` | Return current backend config snapshot | — |
|
||||
| `ai.health` | Return AI bridge health/provider status | — |
|
||||
| `ai.summarize_entry` | Summarize one entry through AI provider | `payload.content`, optional `payload.fileStem` |
|
||||
| `ai.summarize_all` | Summarize a set of entries through AI provider | `payload.entries[]` |
|
||||
| `ai.chat` | Send chat prompt through AI provider bridge | `payload.prompt` |
|
||||
| `ai.embed` | Generate embedding vector through AI provider bridge | `payload.content` |
|
||||
| `search.entries` | Search decrypted entry content with optional parity filters | `payload.dataDirectory`, optional `payload.query`, `payload.section`, `payload.startDate`, `payload.endDate`, `payload.tags[]`, `payload.types[]`, `payload.checked[]`, `payload.unchecked[]` |
|
||||
| `vault.initialize` | Ensure vault directory exists | `payload.password`, `payload.vaultDirectory` |
|
||||
| `vault.load_all` | Load/decrypt all monthly vaults into data directory | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` |
|
||||
| `vault.save_current_month` | Save only current month vault (optimized path) | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory`, optional `payload.nowUtc` |
|
||||
| `vault.rebuild_all` | Rebuild all monthly vaults from decrypted `.md` data | `payload.password`, `payload.vaultDirectory`, `payload.dataDirectory` |
|
||||
| `vault.clear_data_directory` | Clear decrypted data directory and recreate it | `payload.dataDirectory` |
|
||||
|
||||
### Response Format
|
||||
|
||||
@ -142,8 +224,11 @@ vault.unlock → IVaultService (future)
|
||||
vault.lock
|
||||
entries.list → IEntryService (future)
|
||||
entries.create
|
||||
ai.analyze → IAiService (future)
|
||||
ai.chat
|
||||
ai.health → IAiService (implemented bridge)
|
||||
ai.summarize_* → IAiService (implemented bridge)
|
||||
ai.chat → IAiService (implemented bridge)
|
||||
ai.embed → IAiService (implemented bridge)
|
||||
db.status → IJournalDatabaseService (in-progress DB parity)
|
||||
search.query → ISearchService (future)
|
||||
```
|
||||
|
||||
@ -162,5 +247,18 @@ services.AddFragmentServices();
|
||||
```
|
||||
|
||||
This registers:
|
||||
- `IFragmentRepository` → `InMemoryFragmentRepository` (singleton — one shared store)
|
||||
- `IFragmentRepository` → `FileFragmentRepository` (singleton — persisted fragment store)
|
||||
- `IFragmentService` → `FragmentService` (transient — fresh instance per request)
|
||||
|
||||
## Fragment Store Location
|
||||
|
||||
`FileFragmentRepository` persists data to:
|
||||
|
||||
- default: `.journal-sidecar/fragments.json` under current working directory
|
||||
- override: `JOURNAL_FRAGMENT_STORE_PATH` environment variable
|
||||
|
||||
## Legacy Vault Compatibility Note
|
||||
|
||||
The legacy Python placeholder file `_init_vault.vault` is treated as obsolete.
|
||||
During vault load, the C# backend ignores this file for decryption and removes it.
|
||||
This preserves compatibility while migrating older vault directories forward.
|
||||
|
||||
62
scripts/dotnet-min.ps1
Normal file
62
scripts/dotnet-min.ps1
Normal file
@ -0,0 +1,62 @@
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$DotnetArgs
|
||||
)
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
|
||||
# Keep dotnet and NuGet artifacts local to the repo for easy cleanup.
|
||||
$env:DOTNET_CLI_HOME = Join-Path $repoRoot ".dotnet_home"
|
||||
$env:NUGET_PACKAGES = Join-Path $repoRoot ".nuget\packages"
|
||||
$env:NUGET_HTTP_CACHE_PATH = Join-Path $repoRoot ".nuget\http-cache"
|
||||
|
||||
# Keep setup minimal and non-interactive.
|
||||
$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"
|
||||
|
||||
# Clear proxy env vars for this process. The host machine currently points them
|
||||
# to 127.0.0.1:9, which breaks NuGet restore.
|
||||
Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALL_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:http_proxy -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:https_proxy -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:all_proxy -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GIT_HTTP_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GIT_HTTPS_PROXY -ErrorAction SilentlyContinue
|
||||
|
||||
# Prefer offline cert revocation checks to reduce flaky TLS behavior on constrained hosts.
|
||||
$env:NUGET_CERT_REVOCATION_MODE = "offline"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $env:DOTNET_CLI_HOME | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $env:NUGET_PACKAGES | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $env:NUGET_HTTP_CACHE_PATH | Out-Null
|
||||
|
||||
if (-not $DotnetArgs -or $DotnetArgs.Count -eq 0) {
|
||||
Write-Host "Usage: ./scripts/dotnet-min.ps1 <dotnet args>"
|
||||
Write-Host "Example: ./scripts/dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj"
|
||||
exit 2
|
||||
}
|
||||
|
||||
$firstArg = $DotnetArgs[0].ToLowerInvariant()
|
||||
$effectiveArgs = @($DotnetArgs)
|
||||
|
||||
if ($firstArg -in @("restore", "build", "run", "test", "publish", "pack")) {
|
||||
if (-not ($effectiveArgs -contains "-p:RestoreIgnoreFailedSources=true")) {
|
||||
$effectiveArgs += "-p:RestoreIgnoreFailedSources=true"
|
||||
}
|
||||
if (-not ($effectiveArgs -contains "-p:NuGetAudit=false")) {
|
||||
$effectiveArgs += "-p:NuGetAudit=false"
|
||||
}
|
||||
}
|
||||
|
||||
if ($firstArg -eq "restore") {
|
||||
if (-not ($effectiveArgs -contains "--ignore-failed-sources")) {
|
||||
$effectiveArgs += "--ignore-failed-sources"
|
||||
}
|
||||
}
|
||||
|
||||
& dotnet @effectiveArgs
|
||||
exit $LASTEXITCODE
|
||||
57
scripts/nuget-export-cache.ps1
Normal file
57
scripts/nuget-export-cache.ps1
Normal file
@ -0,0 +1,57 @@
|
||||
param(
|
||||
[string]$OutputZip = "nuget-cache-export.zip",
|
||||
[switch]$IncludeDotnetHome
|
||||
)
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$outputPath = if ([System.IO.Path]::IsPathRooted($OutputZip)) { $OutputZip } else { Join-Path $repoRoot $OutputZip }
|
||||
$outputDir = Split-Path -Parent $outputPath
|
||||
if (-not (Test-Path $outputDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "Priming restore cache..."
|
||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Sidecar/Journal.Sidecar.csproj"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Api/Journal.Api.csproj"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.SmokeTests/Journal.SmokeTests.csproj"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$staging = Join-Path $repoRoot ".nuget-export-staging"
|
||||
if (Test-Path $staging) {
|
||||
Remove-Item -Recurse -Force $staging
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
||||
|
||||
$nugetRoot = Join-Path $repoRoot ".nuget"
|
||||
if (-not (Test-Path $nugetRoot)) {
|
||||
Write-Error "No .nuget directory found under $repoRoot"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Copy-Item -Recurse -Force -Path $nugetRoot -Destination (Join-Path $staging ".nuget")
|
||||
if ($IncludeDotnetHome) {
|
||||
$dotnetHome = Join-Path $repoRoot ".dotnet_home"
|
||||
if (Test-Path $dotnetHome) {
|
||||
Copy-Item -Recurse -Force -Path $dotnetHome -Destination (Join-Path $staging ".dotnet_home")
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = @(
|
||||
"exported_utc=$([DateTime]::UtcNow.ToString("o"))"
|
||||
"repo_root=$repoRoot"
|
||||
"include_dotnet_home=$($IncludeDotnetHome.IsPresent)"
|
||||
"note=Copy this zip to target machine and run scripts/nuget-import-cache.ps1"
|
||||
)
|
||||
$manifest | Set-Content -Encoding UTF8 -Path (Join-Path $staging "nuget-cache-manifest.txt")
|
||||
|
||||
if (Test-Path $outputPath) {
|
||||
Remove-Item -Force $outputPath
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $outputPath -Force
|
||||
Remove-Item -Recurse -Force $staging
|
||||
|
||||
Write-Host "NuGet cache export created at: $outputPath"
|
||||
|
||||
25
scripts/nuget-import-cache.ps1
Normal file
25
scripts/nuget-import-cache.ps1
Normal file
@ -0,0 +1,25 @@
|
||||
param(
|
||||
[string]$InputZip = "nuget-cache-export.zip"
|
||||
)
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$inputPath = if ([System.IO.Path]::IsPathRooted($InputZip)) { $InputZip } else { Join-Path $repoRoot $InputZip }
|
||||
|
||||
if (-not (Test-Path $inputPath)) {
|
||||
Write-Error "Input zip not found: $inputPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Importing cache from: $inputPath"
|
||||
Expand-Archive -Path $inputPath -DestinationPath $repoRoot -Force
|
||||
|
||||
Write-Host "Running restore with local cache..."
|
||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Sidecar/Journal.Sidecar.csproj"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Api/Journal.Api.csproj"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.SmokeTests/Journal.SmokeTests.csproj"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Write-Host "Cache import complete."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user