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, "
", "\n", RegexOptions.IgnoreCase);
+ text = Regex.Replace(text, "(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
+ text = Regex.Replace(text, "
]*>", "\n- ", RegexOptions.IgnoreCase);
+ text = Regex.Replace(text, "", "\n", RegexOptions.IgnoreCase);
+ text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
+ text = Regex.Replace(text, "(td|th)>", " ", RegexOptions.IgnoreCase);
+ text = Regex.Replace(text, "
]*>", "\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
? 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? Tags = null,
+ List? Types = null,
+ List? Checked = null,
+ List? Unchecked = null);
}
diff --git a/Journal.Core/Journal.Core.csproj b/Journal.Core/Journal.Core.csproj
index 5831e53..7e67750 100644
--- a/Journal.Core/Journal.Core.csproj
+++ b/Journal.Core/Journal.Core.csproj
@@ -7,7 +7,9 @@
+
+
diff --git a/Journal.Core/Models/Command.cs b/Journal.Core/Models/Command.cs
index 435345a..ac44027 100644
--- a/Journal.Core/Models/Command.cs
+++ b/Journal.Core/Models/Command.cs
@@ -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; }
diff --git a/Journal.Core/Models/Fragment.cs b/Journal.Core/Models/Fragment.cs
index 7e6ac26..6b67db7 100644
--- a/Journal.Core/Models/Fragment.cs
+++ b/Journal.Core/Models/Fragment.cs
@@ -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? 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));
+ }
}
diff --git a/Journal.Core/Models/JournalConfig.cs b/Journal.Core/Models/JournalConfig.cs
new file mode 100644
index 0000000..f540a41
--- /dev/null
+++ b/Journal.Core/Models/JournalConfig.cs
@@ -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);
diff --git a/Journal.Core/Models/JournalEntry.cs b/Journal.Core/Models/JournalEntry.cs
new file mode 100644
index 0000000..e647c55
--- /dev/null
+++ b/Journal.Core/Models/JournalEntry.cs
@@ -0,0 +1,98 @@
+namespace Journal.Core.Models;
+
+public class JournalEntry
+{
+ public string Date { get; set; }
+ public List Fragments { get; set; }
+ public string RawContent { get; set; }
+ public Dictionary Sections { get; set; }
+
+ public JournalEntry(
+ string date,
+ IEnumerable? fragments = null,
+ string rawContent = "",
+ IDictionary? 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(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
+ {
+ "---",
+ "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);
+ }
+}
diff --git a/Journal.Core/Models/ParsedSection.cs b/Journal.Core/Models/ParsedSection.cs
new file mode 100644
index 0000000..bc9ff3c
--- /dev/null
+++ b/Journal.Core/Models/ParsedSection.cs
@@ -0,0 +1,21 @@
+namespace Journal.Core.Models;
+
+public class ParsedSection
+{
+ public string Title { get; set; }
+ public List Content { get; set; }
+ public Dictionary Checkboxes { get; set; }
+
+ public ParsedSection(
+ string title,
+ IEnumerable? content = null,
+ IDictionary? 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(checkboxes);
+ }
+}
diff --git a/Journal.Core/Models/SectionTitles.cs b/Journal.Core/Models/SectionTitles.cs
new file mode 100644
index 0000000..3aaf666
--- /dev/null
+++ b/Journal.Core/Models/SectionTitles.cs
@@ -0,0 +1,20 @@
+namespace Journal.Core.Models;
+
+public static class SectionTitles
+{
+ public static readonly IReadOnlyList 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",
+ ];
+}
diff --git a/Journal.Core/Repositories/FileFragmentRepository.cs b/Journal.Core/Repositories/FileFragmentRepository.cs
new file mode 100644
index 0000000..71e107d
--- /dev/null
+++ b/Journal.Core/Repositories/FileFragmentRepository.cs
@@ -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 _store;
+
+ public FileFragmentRepository() : this(storagePath: null)
+ {
+ }
+
+ public FileFragmentRepository(string? storagePath)
+ {
+ _storagePath = ResolveStoragePath(storagePath);
+ _store = LoadStore(_storagePath);
+ }
+
+ public Task> GetAllAsync()
+ {
+ lock (_lock)
+ {
+ return Task.FromResult(_store.ToList());
+ }
+ }
+
+ public Task GetByIdAsync(Guid id)
+ {
+ lock (_lock)
+ {
+ return Task.FromResult(_store.FirstOrDefault(f => f.Id == id));
+ }
+ }
+
+ public Task AddAsync(Fragment fragment)
+ {
+ ArgumentNullException.ThrowIfNull(fragment);
+ lock (_lock)
+ {
+ Normalize(fragment);
+ _store.Add(fragment);
+ SaveStoreLocked();
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task RemoveAsync(Guid id)
+ {
+ lock (_lock)
+ {
+ var item = _store.FirstOrDefault(f => f.Id == id);
+ if (item is null)
+ return Task.FromResult(false);
+
+ var removed = _store.Remove(item);
+ if (removed)
+ SaveStoreLocked();
+ return Task.FromResult(removed);
+ }
+ }
+
+ public Task UpdateAsync(
+ Guid id,
+ string? type = null,
+ string? description = null,
+ IEnumerable? tags = null,
+ DateTimeOffset? time = null)
+ {
+ lock (_lock)
+ {
+ var item = _store.FirstOrDefault(f => f.Id == id);
+ if (item is null)
+ return Task.FromResult(false);
+
+ if (type != null)
+ {
+ if (string.IsNullOrWhiteSpace(type))
+ throw new ArgumentException("Type cannot be empty", nameof(type));
+ item.Type = type.Trim();
+ }
+
+ if (description != null)
+ {
+ if (string.IsNullOrWhiteSpace(description))
+ throw new ArgumentException("Description cannot be empty", nameof(description));
+ item.Description = description.Trim();
+ }
+
+ if (tags != null)
+ {
+ item.Tags = [..
+ tags.Where(t => !string.IsNullOrWhiteSpace(t))
+ .Select(t => t.Trim())];
+ }
+
+ if (time.HasValue)
+ item.Time = time.Value;
+
+ SaveStoreLocked();
+ return Task.FromResult(true);
+ }
+ }
+
+ public Task> GetByTagAsync(string tag)
+ {
+ var q = tag?.Trim();
+ if (string.IsNullOrWhiteSpace(q))
+ return Task.FromResult(new List());
+
+ lock (_lock)
+ {
+ var items = _store
+ .Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+ return Task.FromResult(items);
+ }
+ }
+
+ public Task> GetByTypeAsync(string type)
+ {
+ var q = type?.Trim();
+ if (string.IsNullOrWhiteSpace(q))
+ return Task.FromResult(new List());
+
+ lock (_lock)
+ {
+ var items = _store
+ .Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ return Task.FromResult(items);
+ }
+ }
+
+ public Task> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
+ {
+ var qType = type?.Trim();
+ var qTag = tag?.Trim();
+
+ lock (_lock)
+ {
+ IEnumerable 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 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>(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 Tags { get; init; } = [];
+ }
+}
diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs
index 97aa165..3bb6fc4 100644
--- a/Journal.Core/ServiceCollectionExtensions.cs
+++ b/Journal.Core/ServiceCollectionExtensions.cs
@@ -8,8 +8,46 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFragmentServices(this IServiceCollection services)
{
- services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddTransient();
+ services.AddTransient();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(provider =>
+ {
+ var config = provider.GetRequiredService().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(provider =>
+ {
+ var config = provider.GetRequiredService().Current;
+ try
+ {
+ return new PythonSidecarSpeechService(config);
+ }
+ catch (Exception ex)
+ {
+ return new DisabledSpeechBridgeService(
+ provider: "python-sidecar",
+ message: $"Python speech sidecar unavailable: {ex.Message}");
+ }
+ });
+ services.AddSingleton();
return services;
}
}
diff --git a/Journal.Core/Services/DisabledAiService.cs b/Journal.Core/Services/DisabledAiService.cs
new file mode 100644
index 0000000..cc5b8ac
--- /dev/null
+++ b/Journal.Core/Services/DisabledAiService.cs
@@ -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 HealthAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(new AiHealthDto(_provider, Enabled: false, Healthy: _healthy, Message: _message));
+
+ public Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_message);
+
+ public Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_message);
+
+ public Task ChatAsync(string prompt, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_message);
+
+ public Task> EmbedAsync(string content, CancellationToken cancellationToken = default) =>
+ Task.FromResult>([]);
+}
diff --git a/Journal.Core/Services/DisabledSpeechBridgeService.cs b/Journal.Core/Services/DisabledSpeechBridgeService.cs
new file mode 100644
index 0000000..9f9a658
--- /dev/null
+++ b/Journal.Core/Services/DisabledSpeechBridgeService.cs
@@ -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 ListDevicesAsync(CancellationToken cancellationToken = default)
+ {
+ var warning = $"{_message} (provider={_provider})";
+ return Task.FromResult(new SpeechDevicesResultDto([], warning));
+ }
+
+ public Task 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));
+ }
+}
diff --git a/Journal.Core/Services/EntrySearchService.cs b/Journal.Core/Services/EntrySearchService.cs
new file mode 100644
index 0000000..e2ff6dc
--- /dev/null
+++ b/Journal.Core/Services/EntrySearchService.cs
@@ -0,0 +1,108 @@
+using Journal.Core.Dtos;
+using System.Globalization;
+
+namespace Journal.Core.Services;
+
+public class EntrySearchService : IEntrySearchService
+{
+ public Task> 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>([]);
+
+ 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();
+ 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>(results);
+ }
+
+ private static HashSet NormalizeSet(IReadOnlyList? values)
+ {
+ if (values is null || values.Count == 0)
+ return [];
+
+ var set = new HashSet(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.");
+ }
+}
diff --git a/Journal.Core/Services/FragmentService.cs b/Journal.Core/Services/FragmentService.cs
index b435660..fef24b7 100644
--- a/Journal.Core/Services/FragmentService.cs
+++ b/Journal.Core/Services/FragmentService.cs
@@ -37,12 +37,16 @@ public class FragmentService : IFragmentService
public async Task 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 RemoveAsync(Guid id) => _repo.RemoveAsync(id);
diff --git a/Journal.Core/Services/IAiService.cs b/Journal.Core/Services/IAiService.cs
new file mode 100644
index 0000000..791873b
--- /dev/null
+++ b/Journal.Core/Services/IAiService.cs
@@ -0,0 +1,12 @@
+using Journal.Core.Dtos;
+
+namespace Journal.Core.Services;
+
+public interface IAiService
+{
+ Task HealthAsync(CancellationToken cancellationToken = default);
+ Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default);
+ Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default);
+ Task ChatAsync(string prompt, CancellationToken cancellationToken = default);
+ Task> EmbedAsync(string content, CancellationToken cancellationToken = default);
+}
diff --git a/Journal.Core/Services/IEntrySearchService.cs b/Journal.Core/Services/IEntrySearchService.cs
new file mode 100644
index 0000000..e9bfede
--- /dev/null
+++ b/Journal.Core/Services/IEntrySearchService.cs
@@ -0,0 +1,8 @@
+using Journal.Core.Dtos;
+
+namespace Journal.Core.Services;
+
+public interface IEntrySearchService
+{
+ Task> SearchEntriesAsync(EntrySearchRequestDto request);
+}
diff --git a/Journal.Core/Services/IJournalConfigService.cs b/Journal.Core/Services/IJournalConfigService.cs
new file mode 100644
index 0000000..3e0a7b5
--- /dev/null
+++ b/Journal.Core/Services/IJournalConfigService.cs
@@ -0,0 +1,8 @@
+using Journal.Core.Models;
+
+namespace Journal.Core.Services;
+
+public interface IJournalConfigService
+{
+ JournalConfig Current { get; }
+}
diff --git a/Journal.Core/Services/IJournalDatabaseService.cs b/Journal.Core/Services/IJournalDatabaseService.cs
new file mode 100644
index 0000000..54b86bc
--- /dev/null
+++ b/Journal.Core/Services/IJournalDatabaseService.cs
@@ -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 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 SchemaTables,
+ string SchemaBootstrapPath,
+ bool RuntimeReady,
+ string RuntimeMessage);
+
+public sealed record JournalDatabaseHydrationResult(
+ string DatabasePath,
+ string SchemaBootstrapPath,
+ int EntryFilesProcessed,
+ bool RuntimeReady,
+ string Message);
diff --git a/Journal.Core/Services/ISpeechBridgeService.cs b/Journal.Core/Services/ISpeechBridgeService.cs
new file mode 100644
index 0000000..0294722
--- /dev/null
+++ b/Journal.Core/Services/ISpeechBridgeService.cs
@@ -0,0 +1,9 @@
+using Journal.Core.Dtos;
+
+namespace Journal.Core.Services;
+
+public interface ISpeechBridgeService
+{
+ Task ListDevicesAsync(CancellationToken cancellationToken = default);
+ Task TranscribeAsync(SpeechTranscribeRequestDto request, CancellationToken cancellationToken = default);
+}
diff --git a/Journal.Core/Services/IVaultCryptoService.cs b/Journal.Core/Services/IVaultCryptoService.cs
new file mode 100644
index 0000000..85418e5
--- /dev/null
+++ b/Journal.Core/Services/IVaultCryptoService.cs
@@ -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);
+}
diff --git a/Journal.Core/Services/IVaultStorageService.cs b/Journal.Core/Services/IVaultStorageService.cs
new file mode 100644
index 0000000..525c1f3
--- /dev/null
+++ b/Journal.Core/Services/IVaultStorageService.cs
@@ -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);
+}
diff --git a/Journal.Core/Services/JournalConfigService.cs b/Journal.Core/Services/JournalConfigService.cs
new file mode 100644
index 0000000..bce9602
--- /dev/null
+++ b/Journal.Core/Services/JournalConfigService.cs
@@ -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;
+ }
+}
diff --git a/Journal.Core/Services/JournalDatabaseService.cs b/Journal.Core/Services/JournalDatabaseService.cs
new file mode 100644
index 0000000..73c0657
--- /dev/null
+++ b/Journal.Core/Services/JournalDatabaseService.cs
@@ -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 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 GetSchemaStatements()
+ {
+ return new Dictionary(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(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}");
+ }
+ }
+}
diff --git a/Journal.Core/Services/JournalParser.cs b/Journal.Core/Services/JournalParser.cs
new file mode 100644
index 0000000..ff00634
--- /dev/null
+++ b/Journal.Core/Services/JournalParser.cs
@@ -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 ParseSections(string content)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+
+ var parsedSections = new Dictionary();
+ string? currentSectionTitle = null;
+ var currentSectionContent = new List();
+ var currentSectionCheckboxes = new Dictionary();
+
+ 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 ParseFragments(string content)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+
+ var fragments = new List();
+ 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();
+ 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;
+ }
+}
diff --git a/Journal.Core/Services/LogRedactor.cs b/Journal.Core/Services/LogRedactor.cs
new file mode 100644
index 0000000..4554174
--- /dev/null
+++ b/Journal.Core/Services/LogRedactor.cs
@@ -0,0 +1,73 @@
+using System.Text.Json;
+
+namespace Journal.Core.Services;
+
+public static class LogRedactor
+{
+ private static readonly HashSet 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 RedactObject(JsonElement element)
+ {
+ var output = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var property in element.EnumerateObject())
+ output[property.Name] = RedactElement(property.Value, property.Name);
+ return output;
+ }
+
+ private static List