Compare commits
No commits in common. "72f82216058f515b184f5647e9bfc04ef3df2164" and "c9c61a279ec8a02be65e09b4d11873f77d570bae" have entirely different histories.
72f8221605
...
c9c61a279e
@ -120,10 +120,8 @@
|
|||||||
let showSaveViewInput = false;
|
let showSaveViewInput = false;
|
||||||
let saveViewName = "";
|
let saveViewName = "";
|
||||||
let hasLoadedSavedViews = false;
|
let hasLoadedSavedViews = false;
|
||||||
|
let lastCalendarTimelineKey = "";
|
||||||
let calendarTimelineDebounce: ReturnType<typeof setTimeout> | null = null;
|
let calendarTimelineDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
let calendarDataVersion = 0;
|
|
||||||
let calendarScheduleKey = "";
|
|
||||||
let calendarRefreshRequestId = 0;
|
|
||||||
let lastActiveSection = "";
|
let lastActiveSection = "";
|
||||||
let calendarLastRefreshedAt = "";
|
let calendarLastRefreshedAt = "";
|
||||||
let calendarDateExplicitlySelected = false;
|
let calendarDateExplicitlySelected = false;
|
||||||
@ -312,44 +310,11 @@
|
|||||||
return tagTokens.some((token) => normalized.includes(token));
|
return tagTokens.some((token) => normalized.includes(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleCalendarRefresh(delayMs = 200) {
|
|
||||||
if (activeSection !== "calendar") return;
|
|
||||||
|
|
||||||
const scheduleKey = [
|
|
||||||
selectedCalendarDate?.key ?? "",
|
|
||||||
calendarYear,
|
|
||||||
calendarMonth,
|
|
||||||
calendarViewMode,
|
|
||||||
calendarSortMode,
|
|
||||||
calendarQuery,
|
|
||||||
calendarTags,
|
|
||||||
calendarTypes,
|
|
||||||
calendarStartDate,
|
|
||||||
calendarEndDate,
|
|
||||||
calendarDataVersion,
|
|
||||||
].join("|");
|
|
||||||
|
|
||||||
if (scheduleKey === calendarScheduleKey && calendarTimelineDebounce) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
calendarScheduleKey = scheduleKey;
|
|
||||||
if (calendarTimelineDebounce) {
|
|
||||||
clearTimeout(calendarTimelineDebounce);
|
|
||||||
}
|
|
||||||
|
|
||||||
calendarTimelineDebounce = setTimeout(() => {
|
|
||||||
void refreshCalendarTimeline();
|
|
||||||
}, delayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshCalendarTimeline(): Promise<void> {
|
async function refreshCalendarTimeline(): Promise<void> {
|
||||||
const requestId = ++calendarRefreshRequestId;
|
|
||||||
calendarBusy = true;
|
calendarBusy = true;
|
||||||
calendarError = "";
|
calendarError = "";
|
||||||
try {
|
try {
|
||||||
const dataDirectory = await getDataDirectory();
|
const dataDirectory = await getDataDirectory();
|
||||||
if (requestId !== calendarRefreshRequestId) return;
|
|
||||||
if (!dataDirectory) {
|
if (!dataDirectory) {
|
||||||
calendarTimelineItems = [];
|
calendarTimelineItems = [];
|
||||||
return;
|
return;
|
||||||
@ -360,14 +325,12 @@
|
|||||||
dataDirectory,
|
dataDirectory,
|
||||||
query: calendarQuery.trim() || undefined,
|
query: calendarQuery.trim() || undefined,
|
||||||
});
|
});
|
||||||
if (requestId !== calendarRefreshRequestId) return;
|
|
||||||
|
|
||||||
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
|
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
|
||||||
listFragments(),
|
listFragments(),
|
||||||
listLists(),
|
listLists(),
|
||||||
listTodoLists(),
|
listTodoLists(),
|
||||||
]);
|
]);
|
||||||
if (requestId !== calendarRefreshRequestId) return;
|
|
||||||
|
|
||||||
const query = calendarQuery.trim();
|
const query = calendarQuery.trim();
|
||||||
const tagTokens = splitFilterTokens(calendarTags);
|
const tagTokens = splitFilterTokens(calendarTags);
|
||||||
@ -462,15 +425,12 @@
|
|||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== calendarRefreshRequestId) return;
|
|
||||||
calendarError = String(error);
|
calendarError = String(error);
|
||||||
calendarTimelineItems = [];
|
calendarTimelineItems = [];
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === calendarRefreshRequestId) {
|
|
||||||
calendarBusy = false;
|
calendarBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function forceRefreshCalendar(options?: {
|
async function forceRefreshCalendar(options?: {
|
||||||
allowWhileBusy?: boolean;
|
allowWhileBusy?: boolean;
|
||||||
@ -858,24 +818,11 @@
|
|||||||
loadSavedViews();
|
loadSavedViews();
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (activeSection === "calendar") {
|
$: calendarTimelineRefreshKey = JSON.stringify({
|
||||||
// Track store mutations without building large string signatures.
|
activeSection,
|
||||||
const storeInvalidationTick = [
|
|
||||||
$entriesStore,
|
|
||||||
$fragmentsStore,
|
|
||||||
$listsStore,
|
|
||||||
$todoListsStore,
|
|
||||||
$todosStore,
|
|
||||||
];
|
|
||||||
void storeInvalidationTick;
|
|
||||||
calendarDataVersion += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (activeSection === "calendar") {
|
|
||||||
const calendarTrigger = [
|
|
||||||
selectedCalendarDate?.key ?? "",
|
|
||||||
calendarYear,
|
calendarYear,
|
||||||
calendarMonth,
|
calendarMonth,
|
||||||
|
selectedCalendarDate,
|
||||||
calendarViewMode,
|
calendarViewMode,
|
||||||
calendarSortMode,
|
calendarSortMode,
|
||||||
calendarQuery,
|
calendarQuery,
|
||||||
@ -883,20 +830,41 @@
|
|||||||
calendarTypes,
|
calendarTypes,
|
||||||
calendarStartDate,
|
calendarStartDate,
|
||||||
calendarEndDate,
|
calendarEndDate,
|
||||||
calendarDataVersion,
|
entriesSig: $entriesStore
|
||||||
];
|
.map((item) => `${item.id}:${item.label}`)
|
||||||
void calendarTrigger;
|
.join("|"),
|
||||||
scheduleCalendarRefresh(200);
|
fragmentsSig: $fragmentsStore
|
||||||
|
.map((item) => `${item.id}:${item.label}`)
|
||||||
|
.join("|"),
|
||||||
|
listsSig: $listsStore.map((item) => `${item.id}:${item.label}`).join("|"),
|
||||||
|
todosSig: $todoListsStore
|
||||||
|
.map((item) => {
|
||||||
|
const todos = ($todosStore[item.id] ?? [])
|
||||||
|
.map((todo) => `${todo.text}:${todo.done ? "1" : "0"}`)
|
||||||
|
.join("~");
|
||||||
|
return `${item.id}:${item.label}:${todos}`;
|
||||||
|
})
|
||||||
|
.join("|"),
|
||||||
|
});
|
||||||
|
$: if (
|
||||||
|
activeSection === "calendar" &&
|
||||||
|
calendarTimelineRefreshKey !== lastCalendarTimelineKey
|
||||||
|
) {
|
||||||
|
lastCalendarTimelineKey = calendarTimelineRefreshKey;
|
||||||
|
if (calendarTimelineDebounce) {
|
||||||
|
clearTimeout(calendarTimelineDebounce);
|
||||||
|
}
|
||||||
|
calendarTimelineDebounce = setTimeout(() => {
|
||||||
|
void refreshCalendarTimeline();
|
||||||
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (activeSection === "calendar" && lastActiveSection !== "calendar") {
|
$: if (activeSection === "calendar" && lastActiveSection !== "calendar") {
|
||||||
calendarDateExplicitlySelected = false;
|
calendarDateExplicitlySelected = false;
|
||||||
calendarScheduleKey = "";
|
|
||||||
void forceRefreshCalendar({ allowWhileBusy: true });
|
void forceRefreshCalendar({ allowWhileBusy: true });
|
||||||
}
|
setTimeout(() => {
|
||||||
$: if (activeSection !== "calendar" && calendarTimelineDebounce) {
|
void forceRefreshCalendar({ allowWhileBusy: true });
|
||||||
clearTimeout(calendarTimelineDebounce);
|
}, 500);
|
||||||
calendarTimelineDebounce = null;
|
|
||||||
}
|
}
|
||||||
$: lastActiveSection = activeSection;
|
$: lastActiveSection = activeSection;
|
||||||
|
|
||||||
|
|||||||
@ -1,139 +0,0 @@
|
|||||||
using Journal.Core.Services.Database;
|
|
||||||
using Journal.Core.Services.Entries;
|
|
||||||
|
|
||||||
namespace Journal.Core.Repositories;
|
|
||||||
|
|
||||||
public sealed class SqliteEntryFileRepository(IDatabaseSessionService session) : IEntryFileRepository
|
|
||||||
{
|
|
||||||
private const string EntryPrefix = "db://entry/";
|
|
||||||
private const string TemplatePrefix = "db://template/";
|
|
||||||
private readonly IDatabaseSessionService _session = session;
|
|
||||||
|
|
||||||
public IReadOnlyList<string> ListMarkdownFiles(string dataDirectory)
|
|
||||||
{
|
|
||||||
var conn = _session.GetConnection();
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
SELECT file_name
|
|
||||||
FROM entry_documents
|
|
||||||
ORDER BY file_name;
|
|
||||||
""";
|
|
||||||
|
|
||||||
var paths = new List<string>();
|
|
||||||
using var reader = cmd.ExecuteReader();
|
|
||||||
while (reader.Read())
|
|
||||||
{
|
|
||||||
if (reader.IsDBNull(0))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var fileName = reader.GetString(0);
|
|
||||||
paths.Add(ToCanonicalPath(fileName));
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ReadFile(string filePath)
|
|
||||||
{
|
|
||||||
var fileName = ResolveFileName(filePath);
|
|
||||||
var conn = _session.GetConnection();
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
SELECT content
|
|
||||||
FROM entry_documents
|
|
||||||
WHERE file_name = @fileName;
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("@fileName", fileName);
|
|
||||||
var result = cmd.ExecuteScalar();
|
|
||||||
if (result is null || result is DBNull)
|
|
||||||
throw new FileNotFoundException($"Entry file not found: {fileName}");
|
|
||||||
return Convert.ToString(result) ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public void WriteFile(string filePath, string content)
|
|
||||||
{
|
|
||||||
var fileName = ResolveFileName(filePath);
|
|
||||||
var isTemplate = EntryFileNaming.IsTemplateFileName(fileName) ? 1 : 0;
|
|
||||||
var conn = _session.GetConnection();
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
INSERT INTO entry_documents (guid, file_name, content, is_template, updated_at)
|
|
||||||
VALUES (@guid, @fileName, @content, @isTemplate, @updatedAt)
|
|
||||||
ON CONFLICT(file_name) DO UPDATE SET
|
|
||||||
content = excluded.content,
|
|
||||||
is_template = excluded.is_template,
|
|
||||||
updated_at = excluded.updated_at;
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("@guid", Guid.NewGuid().ToString("D"));
|
|
||||||
cmd.Parameters.AddWithValue("@fileName", fileName);
|
|
||||||
cmd.Parameters.AddWithValue("@content", content ?? "");
|
|
||||||
cmd.Parameters.AddWithValue("@isTemplate", isTemplate);
|
|
||||||
cmd.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
||||||
cmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AppendFile(string filePath, string content)
|
|
||||||
{
|
|
||||||
var fileName = ResolveFileName(filePath);
|
|
||||||
var existing = FileExists(fileName) ? ReadFile(fileName) : "";
|
|
||||||
WriteFile(fileName, existing + content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool FileExists(string filePath)
|
|
||||||
{
|
|
||||||
var fileName = ResolveFileName(filePath);
|
|
||||||
var conn = _session.GetConnection();
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
SELECT 1
|
|
||||||
FROM entry_documents
|
|
||||||
WHERE file_name = @fileName
|
|
||||||
LIMIT 1;
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("@fileName", fileName);
|
|
||||||
return cmd.ExecuteScalar() is not null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetFullPath(string filePath)
|
|
||||||
{
|
|
||||||
var fileName = ResolveFileName(filePath);
|
|
||||||
return ToCanonicalPath(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetFileName(string filePath) => ResolveFileName(filePath);
|
|
||||||
|
|
||||||
public string GetFileNameWithoutExtension(string filePath)
|
|
||||||
=> Path.GetFileNameWithoutExtension(ResolveFileName(filePath));
|
|
||||||
|
|
||||||
public void EnsureDirectory(string path) { }
|
|
||||||
|
|
||||||
public void DeleteFile(string filePath)
|
|
||||||
{
|
|
||||||
var fileName = ResolveFileName(filePath);
|
|
||||||
var conn = _session.GetConnection();
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "DELETE FROM entry_documents WHERE file_name = @fileName;";
|
|
||||||
cmd.Parameters.AddWithValue("@fileName", fileName);
|
|
||||||
cmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveFileName(string input)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
|
||||||
return "";
|
|
||||||
|
|
||||||
if (input.StartsWith(EntryPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Uri.UnescapeDataString(input[EntryPrefix.Length..]);
|
|
||||||
if (input.StartsWith(TemplatePrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Uri.UnescapeDataString(input[TemplatePrefix.Length..]);
|
|
||||||
|
|
||||||
var fileName = Path.GetFileName(input);
|
|
||||||
return fileName ?? input.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ToCanonicalPath(string fileName)
|
|
||||||
{
|
|
||||||
var prefix = EntryFileNaming.IsTemplateFileName(fileName) ? TemplatePrefix : EntryPrefix;
|
|
||||||
return prefix + Uri.EscapeDataString(fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -58,7 +58,7 @@ public static class ServiceCollectionExtensions
|
|||||||
message: $"Python speech sidecar unavailable: {ex.Message}");
|
message: $"Python speech sidecar unavailable: {ex.Message}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
services.AddSingleton<IEntryFileRepository, SqliteEntryFileRepository>();
|
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
|
||||||
services.AddSingleton<IEntryFileService, EntryFileService>();
|
services.AddSingleton<IEntryFileService, EntryFileService>();
|
||||||
services.AddSingleton<IListRepository, SqliteListRepository>();
|
services.AddSingleton<IListRepository, SqliteListRepository>();
|
||||||
services.AddSingleton<IListService, ListService>();
|
services.AddSingleton<IListService, ListService>();
|
||||||
|
|||||||
@ -14,7 +14,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
|||||||
private static readonly Lock SqliteInitLock = new();
|
private static readonly Lock SqliteInitLock = new();
|
||||||
private static bool _sqliteInitialized;
|
private static bool _sqliteInitialized;
|
||||||
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
||||||
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents"];
|
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items"];
|
||||||
|
|
||||||
private readonly IJournalConfigService _config = config;
|
private readonly IJournalConfigService _config = config;
|
||||||
|
|
||||||
@ -120,16 +120,6 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
|||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
FOREIGN KEY (list_id) REFERENCES todo_lists (id)
|
FOREIGN KEY (list_id) REFERENCES todo_lists (id)
|
||||||
);
|
);
|
||||||
""",
|
|
||||||
["entry_documents"] = """
|
|
||||||
CREATE TABLE IF NOT EXISTS entry_documents (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
guid TEXT UNIQUE,
|
|
||||||
file_name TEXT NOT NULL UNIQUE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
is_template INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at TEXT
|
|
||||||
);
|
|
||||||
"""
|
"""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,19 @@
|
|||||||
using Journal.Core.Dtos;
|
using Journal.Core.Dtos;
|
||||||
using Journal.Core.Repositories;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Journal.Core.Services.Entries;
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService
|
public class EntrySearchService : IEntrySearchService
|
||||||
{
|
{
|
||||||
private readonly IEntryFileRepository _repo = repo;
|
|
||||||
private readonly Lock _cacheLock = new();
|
|
||||||
private readonly Dictionary<string, CachedEntry> _entryCache = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public Task<IReadOnlyList<EntrySearchResultDto>> SearchEntriesAsync(EntrySearchRequestDto request)
|
public Task<IReadOnlyList<EntrySearchResultDto>> SearchEntriesAsync(EntrySearchRequestDto request)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
if (string.IsNullOrWhiteSpace(request.DataDirectory))
|
if (string.IsNullOrWhiteSpace(request.DataDirectory))
|
||||||
throw new ArgumentException("Data directory is required.", nameof(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 hasQuery = !string.IsNullOrWhiteSpace(request.Query);
|
||||||
var query = request.Query?.Trim() ?? "";
|
var query = request.Query?.Trim() ?? "";
|
||||||
var hasSectionFilter = !string.IsNullOrWhiteSpace(request.Section);
|
var hasSectionFilter = !string.IsNullOrWhiteSpace(request.Section);
|
||||||
@ -35,20 +31,17 @@ public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService
|
|||||||
if (startDate.HasValue && endDate.HasValue && startDate.Value > endDate.Value)
|
if (startDate.HasValue && endDate.HasValue && startDate.Value > endDate.Value)
|
||||||
throw new ArgumentException("startDate cannot be after endDate.");
|
throw new ArgumentException("startDate cannot be after endDate.");
|
||||||
|
|
||||||
var currentFiles = _repo.ListMarkdownFiles(request.DataDirectory)
|
|
||||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var currentFileSet = new HashSet<string>(currentFiles, StringComparer.Ordinal);
|
|
||||||
var results = new List<EntrySearchResultDto>();
|
var results = new List<EntrySearchResultDto>();
|
||||||
foreach (var filePath in currentFiles)
|
foreach (var filePath in Directory.GetFiles(request.DataDirectory, "*.md")
|
||||||
|
.OrderBy(Path.GetFileName, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
var fileName = _repo.GetFileName(filePath);
|
var fileName = Path.GetFileName(filePath);
|
||||||
if (EntryFileNaming.IsTemplateFileName(fileName))
|
if (EntryFileNaming.IsTemplateFileName(fileName))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var cached = GetOrBuildCachedEntry(filePath);
|
var fileStem = Path.GetFileNameWithoutExtension(filePath);
|
||||||
var entry = cached.Result.Entry;
|
var rawContent = File.ReadAllText(filePath);
|
||||||
|
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||||
|
|
||||||
if (startDate.HasValue || endDate.HasValue)
|
if (startDate.HasValue || endDate.HasValue)
|
||||||
{
|
{
|
||||||
@ -64,7 +57,7 @@ public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService
|
|||||||
var contentMatch = true;
|
var contentMatch = true;
|
||||||
if (hasQuery)
|
if (hasQuery)
|
||||||
{
|
{
|
||||||
var haystack = hasSectionFilter ? GetSection(entry, section) : entry.RawContent;
|
var haystack = hasSectionFilter ? entry.GetSection(section) : entry.RawContent;
|
||||||
contentMatch = haystack.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0;
|
contentMatch = haystack.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
}
|
}
|
||||||
if (!contentMatch)
|
if (!contentMatch)
|
||||||
@ -83,103 +76,12 @@ public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService
|
|||||||
if (!checkboxMatch)
|
if (!checkboxMatch)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
results.Add(cached.Result);
|
results.Add(new EntrySearchResultDto(fileName, entry.ToDto()));
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoveStaleCacheEntries(currentFileSet);
|
|
||||||
|
|
||||||
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>(results);
|
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CachedEntry GetOrBuildCachedEntry(string filePath)
|
|
||||||
{
|
|
||||||
var diskSignature = TryGetDiskFileSignature(filePath);
|
|
||||||
|
|
||||||
if (diskSignature is not null)
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
if (_entryCache.TryGetValue(filePath, out var cached) &&
|
|
||||||
cached.Signature == diskSignature.Value)
|
|
||||||
{
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileName = _repo.GetFileName(filePath);
|
|
||||||
var fileStem = _repo.GetFileNameWithoutExtension(filePath);
|
|
||||||
var rawContent = _repo.ReadFile(filePath);
|
|
||||||
var signature = diskSignature ?? BuildContentSignature(rawContent);
|
|
||||||
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
if (_entryCache.TryGetValue(filePath, out var cached) &&
|
|
||||||
cached.Signature == signature)
|
|
||||||
{
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem).ToDto();
|
|
||||||
var built = new CachedEntry(signature, new EntrySearchResultDto(fileName, entry));
|
|
||||||
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
_entryCache[filePath] = built;
|
|
||||||
}
|
|
||||||
|
|
||||||
return built;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static FileSignature? TryGetDiskFileSignature(string filePath)
|
|
||||||
{
|
|
||||||
if (filePath.StartsWith("db://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return null;
|
|
||||||
if (!File.Exists(filePath))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var info = new FileInfo(filePath);
|
|
||||||
return new FileSignature(info.Length, info.LastWriteTimeUtc.Ticks, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static FileSignature BuildContentSignature(string content)
|
|
||||||
{
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(content ?? "");
|
|
||||||
var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
|
||||||
return new FileSignature(bytes.Length, 0, hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveStaleCacheEntries(HashSet<string> currentFileSet)
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
if (_entryCache.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var staleKeys = _entryCache.Keys
|
|
||||||
.Where(path => !currentFileSet.Contains(path))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var key in staleKeys)
|
|
||||||
_entryCache.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetSection(JournalEntryDto entry, string sectionTitle)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(sectionTitle))
|
|
||||||
return "";
|
|
||||||
|
|
||||||
foreach (var (key, value) in entry.Sections)
|
|
||||||
{
|
|
||||||
if (string.Equals(key, sectionTitle, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return string.Join("\n", value.Content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HashSet<string> NormalizeSet(IReadOnlyList<string>? values)
|
private static HashSet<string> NormalizeSet(IReadOnlyList<string>? values)
|
||||||
{
|
{
|
||||||
if (values is null || values.Count == 0)
|
if (values is null || values.Count == 0)
|
||||||
@ -206,7 +108,4 @@ public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService
|
|||||||
|
|
||||||
throw new ArgumentException($"Invalid {argumentName} value. Expected yyyy-MM-dd.");
|
throw new ArgumentException($"Invalid {argumentName} value. Expected yyyy-MM-dd.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct FileSignature(long Length, long LastWriteUtcTicks, string? ContentHash);
|
|
||||||
private sealed record CachedEntry(FileSignature Signature, EntrySearchResultDto Result);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace Journal.Core.Services.Vault;
|
namespace Journal.Core.Services.Vault;
|
||||||
|
|
||||||
public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService
|
public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService
|
||||||
{
|
{
|
||||||
private readonly IVaultCryptoService _crypto = crypto;
|
private readonly IVaultCryptoService _crypto = crypto;
|
||||||
|
private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal);
|
||||||
private readonly object _vaultIoLock = new();
|
private readonly object _vaultIoLock = new();
|
||||||
|
|
||||||
private const string DatabaseVaultPrefix = "_db_";
|
|
||||||
private const string DatabaseVaultSuffix = ".vault";
|
|
||||||
|
|
||||||
public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault";
|
public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault";
|
||||||
|
|
||||||
public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory)
|
public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory)
|
||||||
@ -19,11 +20,64 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
|
|
||||||
lock (_vaultIoLock)
|
lock (_vaultIoLock)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(dataDirectory);
|
_monthFingerprintCache.Clear();
|
||||||
|
PrepareDataDirectory(dataDirectory);
|
||||||
|
|
||||||
if (!Directory.Exists(vaultDirectory))
|
if (!Directory.Exists(vaultDirectory))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return RestoreDatabaseVaults(password, vaultDirectory, dataDirectory);
|
var vaultFiles = Directory.GetFiles(vaultDirectory, "*.vault")
|
||||||
|
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (vaultFiles.Length == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Restore database vault files first
|
||||||
|
RestoreDatabaseVaults(password, vaultDirectory, dataDirectory);
|
||||||
|
|
||||||
|
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 (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Failed to delete legacy vault file {fileName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsReservedVaultFile(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
anyVaultFiles = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encrypted = File.ReadAllBytes(vaultFile);
|
||||||
|
var decryptedZip = _crypto.DecryptData(encrypted, password);
|
||||||
|
ExtractZipContent(decryptedZip, dataDirectory);
|
||||||
|
anyDecrypted = true;
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Decryption failed for {fileName} (likely wrong password)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Failed to load vault {fileName}: {ex.GetType().Name} - {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anyDecrypted && anyVaultFiles)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,8 +91,28 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
if (!Directory.Exists(dataDirectory))
|
if (!Directory.Exists(dataDirectory))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
|
var monthKey = now.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||||
return true;
|
var filesInMonth = Directory.GetFiles(dataDirectory, "*.md")
|
||||||
|
.Where(path => Path.GetFileNameWithoutExtension(path).StartsWith(monthKey, StringComparison.Ordinal))
|
||||||
|
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var savedMonth = false;
|
||||||
|
if (filesInMonth.Count > 0)
|
||||||
|
{
|
||||||
|
var currentFingerprint = ComputeMonthFingerprint(filesInMonth);
|
||||||
|
if (!_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) ||
|
||||||
|
!string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
|
||||||
|
savedMonth = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also persist custom-named entries alongside the current month vault
|
||||||
|
SaveCustomEntries(password, vaultDirectory, dataDirectory);
|
||||||
|
|
||||||
|
return savedMonth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +126,27 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
if (!Directory.Exists(dataDirectory))
|
if (!Directory.Exists(dataDirectory))
|
||||||
return;
|
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);
|
||||||
|
|
||||||
|
SaveCustomEntries(password, vaultDirectory, dataDirectory);
|
||||||
SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
|
SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -62,78 +157,17 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
throw new ArgumentException("Data directory is required.", nameof(dataDirectory));
|
throw new ArgumentException("Data directory is required.", nameof(dataDirectory));
|
||||||
|
|
||||||
lock (_vaultIoLock)
|
lock (_vaultIoLock)
|
||||||
|
{
|
||||||
|
PrepareDataDirectory(dataDirectory);
|
||||||
|
_monthFingerprintCache.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrepareDataDirectory(string dataDirectory)
|
||||||
{
|
{
|
||||||
DeleteDirectoryWithRetries(dataDirectory);
|
DeleteDirectoryWithRetries(dataDirectory);
|
||||||
Directory.CreateDirectory(dataDirectory);
|
Directory.CreateDirectory(dataDirectory);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
|
|
||||||
{
|
|
||||||
var dbFiles = Directory.GetFiles(dataDirectory, "*.db");
|
|
||||||
foreach (var dbPath in dbFiles)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var dbFileName = Path.GetFileName(dbPath);
|
|
||||||
var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}";
|
|
||||||
var vaultPath = Path.Combine(vaultDirectory, vaultFileName);
|
|
||||||
|
|
||||||
var dbBytes = File.ReadAllBytes(dbPath);
|
|
||||||
var encrypted = _crypto.EncryptData(dbBytes, password);
|
|
||||||
File.WriteAllBytes(vaultPath, encrypted);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
|
|
||||||
{
|
|
||||||
var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}");
|
|
||||||
if (dbVaultFiles.Length == 0)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
var anyRestored = false;
|
|
||||||
foreach (var vaultFile in dbVaultFiles)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var vaultFileName = Path.GetFileName(vaultFile);
|
|
||||||
var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length];
|
|
||||||
if (string.IsNullOrWhiteSpace(dbFileName))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var encrypted = File.ReadAllBytes(vaultFile);
|
|
||||||
var dbBytes = _crypto.DecryptData(encrypted, password);
|
|
||||||
var targetPath = Path.Combine(dataDirectory, dbFileName);
|
|
||||||
File.WriteAllBytes(targetPath, dbBytes);
|
|
||||||
anyRestored = true;
|
|
||||||
}
|
|
||||||
catch (CryptographicException)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return anyRestored;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 static void DeleteDirectoryWithRetries(string dataDirectory, int retries = 5, int delayMs = 200)
|
private static void DeleteDirectoryWithRetries(string dataDirectory, int retries = 5, int delayMs = 200)
|
||||||
{
|
{
|
||||||
@ -159,4 +193,194 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
|
|
||||||
Directory.Delete(dataDirectory, recursive: true);
|
Directory.Delete(dataDirectory, recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom entries vault helpers ──────────────────────────────
|
||||||
|
|
||||||
|
private const string CustomEntriesVaultFileName = "_custom_entries.vault";
|
||||||
|
|
||||||
|
private List<string> GetCustomEntryFiles(string dataDirectory)
|
||||||
|
{
|
||||||
|
return Directory.GetFiles(dataDirectory, "*.md")
|
||||||
|
.Where(path =>
|
||||||
|
{
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(path);
|
||||||
|
return !DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _);
|
||||||
|
})
|
||||||
|
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveCustomEntries(string password, string vaultDirectory, string dataDirectory)
|
||||||
|
{
|
||||||
|
var customFiles = GetCustomEntryFiles(dataDirectory);
|
||||||
|
var vaultPath = Path.Combine(vaultDirectory, CustomEntriesVaultFileName);
|
||||||
|
|
||||||
|
if (customFiles.Count == 0)
|
||||||
|
{
|
||||||
|
// Remove stale custom vault if no custom entries remain
|
||||||
|
if (File.Exists(vaultPath))
|
||||||
|
File.Delete(vaultPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var zipBytes = CreateMonthlyArchive(customFiles);
|
||||||
|
var encrypted = _crypto.EncryptData(zipBytes, password);
|
||||||
|
File.WriteAllBytes(vaultPath, encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCustomEntriesVaultFile(string fileName)
|
||||||
|
=> string.Equals(fileName, CustomEntriesVaultFileName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// ── Database vault helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
private const string DatabaseVaultPrefix = "_db_";
|
||||||
|
private const string DatabaseVaultSuffix = ".vault";
|
||||||
|
|
||||||
|
private static bool IsReservedVaultFile(string fileName)
|
||||||
|
=> fileName.StartsWith(DatabaseVaultPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
|
||||||
|
{
|
||||||
|
var dbFiles = Directory.GetFiles(dataDirectory, "*.db");
|
||||||
|
foreach (var dbPath in dbFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dbFileName = Path.GetFileName(dbPath);
|
||||||
|
var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}";
|
||||||
|
var vaultPath = Path.Combine(vaultDirectory, vaultFileName);
|
||||||
|
|
||||||
|
var dbBytes = File.ReadAllBytes(dbPath);
|
||||||
|
var encrypted = _crypto.EncryptData(dbBytes, password);
|
||||||
|
File.WriteAllBytes(vaultPath, encrypted);
|
||||||
|
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Saved database vault: {vaultFileName}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
|
||||||
|
{
|
||||||
|
var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}");
|
||||||
|
foreach (var vaultFile in dbVaultFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var vaultFileName = Path.GetFileName(vaultFile);
|
||||||
|
// Strip prefix and suffix to get original DB filename
|
||||||
|
var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length];
|
||||||
|
if (string.IsNullOrWhiteSpace(dbFileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var encrypted = File.ReadAllBytes(vaultFile);
|
||||||
|
var dbBytes = _crypto.DecryptData(encrypted, password);
|
||||||
|
var targetPath = Path.Combine(dataDirectory, dbFileName);
|
||||||
|
File.WriteAllBytes(targetPath, dbBytes);
|
||||||
|
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Restored database from vault: {vaultFileName} → {dbFileName}");
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user