massive backend migration to c#

This commit is contained in:
stan44 2026-02-23 19:57:18 -06:00
parent d0fac4199a
commit 14b8e7a339
45 changed files with 5483 additions and 70 deletions

3
.gitignore vendored
View File

@ -21,6 +21,9 @@ obj/
**/packages/
project.lock.json
project.fragment.lock.json
.nuget/
.dotnet_home/
.journal-sidecar/
# Publish output
publish/

View File

@ -10,4 +10,8 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -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":
###

View File

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

View File

@ -0,0 +1,7 @@
namespace Journal.Core.Dtos;
public sealed record AiHealthDto(
string Provider,
bool Enabled,
bool Healthy,
string Message);

View 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);

View 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);

View File

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

View File

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

View File

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

View File

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

View 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);

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

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

View 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",
];
}

View 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; } = [];
}
}

View File

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

View 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>>([]);
}

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

View 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.");
}
}

View File

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

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

View File

@ -0,0 +1,8 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
public interface IEntrySearchService
{
Task<IReadOnlyList<EntrySearchResultDto>> SearchEntriesAsync(EntrySearchRequestDto request);
}

View File

@ -0,0 +1,8 @@
using Journal.Core.Models;
namespace Journal.Core.Services;
public interface IJournalConfigService
{
JournalConfig Current { get; }
}

View 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);

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

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

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

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

View 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}");
}
}
}

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

View 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)";
}
}

View 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.
}
}
}

View 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.
}
}
}

View 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; } = [];
}
}

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

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

View File

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

View 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"
}
]

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

File diff suppressed because it is too large Load Diff

112
README.md
View File

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

View 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"

View 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."