- Move standalone fragment storage from unencrypted SQLite to the existing encrypted SQLCipher database (journal_cache.db) - Add IDatabaseSessionService/DatabaseSessionService for shared encrypted connection management after authentication - Update fragments table schema: nullable entry_id, add guid column - Reorganize flat Services/ directory (28 files) into 9 domain modules: Ai, Config, Database, Entries, Fragments, Logging, Sidecar, Speech, Vault - Update all namespace declarations and using statements across all projects - Update REFACTORING_SUMMARY.md with all changes Co-Authored-By: Warp <agent@warp.dev>
321 lines
11 KiB
C#
321 lines
11 KiB
C#
using Journal.Core.Models;
|
|
using Journal.Core.Services.Database;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace Journal.Core.Repositories;
|
|
|
|
public sealed class SqliteFragmentRepository(IDatabaseSessionService session) : IFragmentRepository
|
|
{
|
|
private readonly IDatabaseSessionService _session = session;
|
|
|
|
public Task<List<Fragment>> GetAllAsync()
|
|
{
|
|
var conn = _session.GetConnection();
|
|
var fragments = ReadAllFragments(conn);
|
|
return Task.FromResult(fragments);
|
|
}
|
|
|
|
public Task<Fragment?> GetByIdAsync(Guid id)
|
|
{
|
|
var conn = _session.GetConnection();
|
|
var fragment = ReadFragment(conn, id);
|
|
return Task.FromResult(fragment);
|
|
}
|
|
|
|
public Task AddAsync(Fragment fragment)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(fragment);
|
|
Normalize(fragment);
|
|
var conn = _session.GetConnection();
|
|
InsertFragment(conn, fragment);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<bool> RemoveAsync(Guid id)
|
|
{
|
|
var conn = _session.GetConnection();
|
|
var deleted = DeleteFragment(conn, id);
|
|
return Task.FromResult(deleted);
|
|
}
|
|
|
|
public Task<bool> UpdateAsync(
|
|
Guid id,
|
|
string? type = null,
|
|
string? description = null,
|
|
IEnumerable<string>? tags = null,
|
|
DateTimeOffset? time = null)
|
|
{
|
|
var conn = _session.GetConnection();
|
|
var existing = ReadFragment(conn, id);
|
|
if (existing is null)
|
|
return Task.FromResult(false);
|
|
|
|
if (type != null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(type))
|
|
throw new ArgumentException("Type cannot be empty", nameof(type));
|
|
existing.Type = type.Trim();
|
|
}
|
|
|
|
if (description != null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(description))
|
|
throw new ArgumentException("Description cannot be empty", nameof(description));
|
|
existing.Description = description.Trim();
|
|
}
|
|
|
|
if (tags != null)
|
|
{
|
|
existing.Tags = [..
|
|
tags.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
.Select(t => t.Trim())];
|
|
}
|
|
|
|
if (time.HasValue)
|
|
existing.Time = time.Value;
|
|
|
|
UpdateFragmentRow(conn, existing);
|
|
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>());
|
|
|
|
var conn = _session.GetConnection();
|
|
var all = ReadAllFragments(conn);
|
|
var items = all
|
|
.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>());
|
|
|
|
var conn = _session.GetConnection();
|
|
var all = ReadAllFragments(conn);
|
|
var items = all
|
|
.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 conn = _session.GetConnection();
|
|
IEnumerable<Fragment> results = ReadAllFragments(conn);
|
|
|
|
var qType = type?.Trim();
|
|
var qTag = tag?.Trim();
|
|
|
|
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 helpers ──────────────────────────────────────────────
|
|
|
|
private static void InsertFragment(SqliteConnection conn, Fragment f)
|
|
{
|
|
using var tx = conn.BeginTransaction();
|
|
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
INSERT INTO fragments (guid, entry_id, type, description, time)
|
|
VALUES (@guid, NULL, @type, @description, @time);
|
|
""";
|
|
cmd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
|
|
cmd.Parameters.AddWithValue("@type", f.Type);
|
|
cmd.Parameters.AddWithValue("@description", f.Description);
|
|
cmd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
|
|
cmd.ExecuteNonQuery();
|
|
|
|
var fragmentRowId = GetFragmentRowId(conn, f.Id);
|
|
if (fragmentRowId.HasValue)
|
|
InsertTags(conn, fragmentRowId.Value, f.Tags);
|
|
|
|
tx.Commit();
|
|
}
|
|
|
|
private static void UpdateFragmentRow(SqliteConnection conn, Fragment f)
|
|
{
|
|
using var tx = conn.BeginTransaction();
|
|
|
|
using var upd = conn.CreateCommand();
|
|
upd.CommandText = """
|
|
UPDATE fragments SET type = @type, description = @description, time = @time
|
|
WHERE guid = @guid AND entry_id IS NULL;
|
|
""";
|
|
upd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
|
|
upd.Parameters.AddWithValue("@type", f.Type);
|
|
upd.Parameters.AddWithValue("@description", f.Description);
|
|
upd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
|
|
upd.ExecuteNonQuery();
|
|
|
|
var fragmentRowId = GetFragmentRowId(conn, f.Id);
|
|
if (fragmentRowId.HasValue)
|
|
{
|
|
using var del = conn.CreateCommand();
|
|
del.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
|
|
del.Parameters.AddWithValue("@id", fragmentRowId.Value);
|
|
del.ExecuteNonQuery();
|
|
|
|
InsertTags(conn, fragmentRowId.Value, f.Tags);
|
|
}
|
|
|
|
tx.Commit();
|
|
}
|
|
|
|
private static bool DeleteFragment(SqliteConnection conn, Guid id)
|
|
{
|
|
using var tx = conn.BeginTransaction();
|
|
|
|
var fragmentRowId = GetFragmentRowId(conn, id);
|
|
if (fragmentRowId.HasValue)
|
|
{
|
|
using var delTags = conn.CreateCommand();
|
|
delTags.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
|
|
delTags.Parameters.AddWithValue("@id", fragmentRowId.Value);
|
|
delTags.ExecuteNonQuery();
|
|
}
|
|
|
|
using var delFrag = conn.CreateCommand();
|
|
delFrag.CommandText = "DELETE FROM fragments WHERE guid = @guid AND entry_id IS NULL;";
|
|
delFrag.Parameters.AddWithValue("@guid", id.ToString("D"));
|
|
var rows = delFrag.ExecuteNonQuery();
|
|
|
|
tx.Commit();
|
|
return rows > 0;
|
|
}
|
|
|
|
private static Fragment? ReadFragment(SqliteConnection conn, Guid id)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
SELECT id, guid, type, description, time
|
|
FROM fragments WHERE guid = @guid AND entry_id IS NULL;
|
|
""";
|
|
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
|
|
|
using var reader = cmd.ExecuteReader();
|
|
if (!reader.Read())
|
|
return null;
|
|
|
|
var fragment = MapRow(reader);
|
|
fragment.Tags = ReadTags(conn, reader.GetInt64(0));
|
|
return fragment;
|
|
}
|
|
|
|
private static List<Fragment> ReadAllFragments(SqliteConnection conn)
|
|
{
|
|
var fragments = new List<Fragment>();
|
|
var rowIds = new List<long>();
|
|
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
SELECT id, guid, type, description, time
|
|
FROM fragments WHERE guid IS NOT NULL AND entry_id IS NULL
|
|
ORDER BY time;
|
|
""";
|
|
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
fragments.Add(MapRow(reader));
|
|
rowIds.Add(reader.GetInt64(0));
|
|
}
|
|
|
|
for (var i = 0; i < fragments.Count; i++)
|
|
fragments[i].Tags = ReadTags(conn, rowIds[i]);
|
|
|
|
return fragments;
|
|
}
|
|
|
|
private static List<string> ReadTags(SqliteConnection conn, long fragmentRowId)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
SELECT t.name FROM tags t
|
|
INNER JOIN fragment_tags ft ON ft.tag_id = t.id
|
|
WHERE ft.fragment_id = @id
|
|
ORDER BY t.name;
|
|
""";
|
|
cmd.Parameters.AddWithValue("@id", fragmentRowId);
|
|
|
|
var tags = new List<string>();
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
tags.Add(reader.GetString(0));
|
|
|
|
return tags;
|
|
}
|
|
|
|
private static long? GetFragmentRowId(SqliteConnection conn, Guid guid)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT id FROM fragments WHERE guid = @guid;";
|
|
cmd.Parameters.AddWithValue("@guid", guid.ToString("D"));
|
|
var result = cmd.ExecuteScalar();
|
|
return result is long id ? id : null;
|
|
}
|
|
|
|
private static void InsertTags(SqliteConnection conn, long fragmentRowId, List<string> tags)
|
|
{
|
|
if (tags.Count == 0) return;
|
|
|
|
foreach (var tag in tags)
|
|
{
|
|
// Upsert into tags table
|
|
using var upsert = conn.CreateCommand();
|
|
upsert.CommandText = "INSERT OR IGNORE INTO tags (name) VALUES (@name);";
|
|
upsert.Parameters.AddWithValue("@name", tag);
|
|
upsert.ExecuteNonQuery();
|
|
|
|
// Get tag id
|
|
using var getTagId = conn.CreateCommand();
|
|
getTagId.CommandText = "SELECT id FROM tags WHERE name = @name;";
|
|
getTagId.Parameters.AddWithValue("@name", tag);
|
|
var tagId = (long)getTagId.ExecuteScalar()!;
|
|
|
|
// Link fragment to tag
|
|
using var link = conn.CreateCommand();
|
|
link.CommandText = "INSERT OR IGNORE INTO fragment_tags (fragment_id, tag_id) VALUES (@fid, @tid);";
|
|
link.Parameters.AddWithValue("@fid", fragmentRowId);
|
|
link.Parameters.AddWithValue("@tid", tagId);
|
|
link.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
private static Fragment MapRow(SqliteDataReader reader)
|
|
{
|
|
// columns: id (int), guid (text), type (text), description (text), time (text)
|
|
var guid = Guid.Parse(reader.GetString(1));
|
|
var type = reader.GetString(2);
|
|
var description = reader.IsDBNull(3) ? "" : reader.GetString(3);
|
|
var time = reader.IsDBNull(4)
|
|
? DateTimeOffset.MinValue
|
|
: DateTimeOffset.Parse(reader.GetString(4));
|
|
return new Fragment(guid, type, description, time);
|
|
}
|
|
|
|
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())];
|
|
}
|
|
}
|