feat(templates): add markdown templates and editor insertion flow

This commit is contained in:
Jacob Schmidt 2026-02-26 21:47:26 -06:00
parent 7c3161c61b
commit 64c06081f0
15 changed files with 583 additions and 21 deletions

View File

@ -0,0 +1,90 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
export type EntryTemplateItemDto = {
fileName: string;
filePath: string;
};
export type EntryTemplateLoadResultDto = {
fileName: string;
filePath: string;
content: string;
};
export type EntryTemplateSaveResultDto = {
filePath: string;
};
type EntryTemplateItemDtoRaw = {
fileName?: string;
filePath?: string;
FileName?: string;
FilePath?: string;
};
type EntryTemplateLoadResultDtoRaw = {
fileName?: string;
filePath?: string;
content?: string;
FileName?: string;
FilePath?: string;
Content?: string;
};
type EntryTemplateSaveResultDtoRaw = {
filePath?: string;
FilePath?: string;
};
function normalizeTemplateItem(raw: EntryTemplateItemDtoRaw): EntryTemplateItemDto {
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", "")
};
}
export async function listEntryTemplates(dataDirectory?: string): Promise<EntryTemplateItemDto[]> {
const data = await sendCommand<EntryTemplateItemDtoRaw[]>({
action: "templates.list",
payload: { dataDirectory }
});
return data.map(normalizeTemplateItem).filter((item) => Boolean(item.filePath));
}
export async function loadEntryTemplate(filePath: string): Promise<EntryTemplateLoadResultDto> {
const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({
action: "templates.load",
payload: { filePath }
});
return {
fileName: pickCase(data, "fileName", "FileName", ""),
filePath: pickCase(data, "filePath", "FilePath", ""),
content: pickCase(data, "content", "Content", "")
};
}
export async function saveEntryTemplate(payload: {
name: string;
content: string;
filePath?: string;
dataDirectory?: string;
}): Promise<EntryTemplateSaveResultDto> {
const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({
action: "templates.save",
payload
});
return {
filePath: pickCase(data, "filePath", "FilePath", "")
};
}
export async function deleteEntryTemplate(filePath: string): Promise<boolean> {
return sendCommand<boolean>({
action: "templates.delete",
payload: { filePath }
});
}

View File

@ -54,6 +54,8 @@
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow: hidden;
}
.editor-empty {

View File

@ -322,6 +322,8 @@
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
}
.panel-header {
@ -388,6 +390,8 @@
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: auto;
li {
display: flex;

View File

@ -1,5 +1,7 @@
<script lang="ts">
import { listEntryTemplates, loadEntryTemplate, type EntryTemplateItemDto } from "$lib/backend/templates";
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
import { onMount } from "svelte";
export let openDocumentId = "";
export let openDocumentName = "";
@ -10,6 +12,10 @@
let lastOpenDocumentId = openDocumentId;
export let previewOnly = true;
let editorInput: HTMLTextAreaElement | null = null;
let templateOptions: EntryTemplateItemDto[] = [];
let templatesBusy = false;
let templateError = "";
let templateRefreshRequested = false;
function updateDraft(value: string) {
markdownText = value;
@ -61,10 +67,68 @@
applyWrap("[", "](https://example.com)");
}
async function refreshTemplates() {
templatesBusy = true;
templateError = "";
try {
templateOptions = await listEntryTemplates();
} catch (error) {
templateError = String(error);
templateOptions = [];
} finally {
templatesBusy = false;
}
}
function insertTextAtCursor(content: string) {
const current = markdownText;
if (!editorInput) {
const spacer = current.endsWith("\n") || !current ? "" : "\n\n";
updateDraft(`${current}${spacer}${content}`);
return;
}
const start = editorInput.selectionStart ?? current.length;
const end = editorInput.selectionEnd ?? start;
const next = `${current.slice(0, start)}${content}${current.slice(end)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + content.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
async function applyTemplateByPath(filePath: string) {
if (!filePath) return;
try {
const loaded = await loadEntryTemplate(filePath);
insertTextAtCursor(loaded.content);
} catch (error) {
templateError = String(error);
}
}
onMount(() => {
void refreshTemplates();
});
$: if (!previewOnly && !templateRefreshRequested) {
templateRefreshRequested = true;
void refreshTemplates();
}
$: if (previewOnly) {
templateRefreshRequested = false;
}
$: if (openDocumentId !== lastOpenDocumentId) {
markdownText = openDocumentContent;
lastOpenDocumentId = openDocumentId;
}
$: isEntryDocument = openDocumentId.startsWith("entries/file/") || openDocumentId.startsWith("entries/draft-");
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
$: renderedHtml = renderMarkdown(markdownText);
</script>
@ -94,6 +158,26 @@
<option value="5">H5</option>
<option value="6">H6</option>
</select>
{#if isEntryDocument}
<select
class="toolbar-select"
aria-label="Insert template"
disabled={templatesBusy}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const filePath = target.value;
if (filePath) {
void applyTemplateByPath(filePath);
}
target.value = "";
}}
>
<option value="">{templatesBusy ? "Loading templates..." : "Template"}</option>
{#each templateOptions as template}
<option value={template.filePath}>{template.fileName.replace(/\.template\.md$/i, "")}</option>
{/each}
</select>
{/if}
<button type="button" on:click={() => applyWrap("**")}>Bold</button>
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
<button type="button" on:click={insertLink}>Link</button>
@ -101,6 +185,9 @@
<button type="button" on:click={() => applyLinePrefix("1. ")}>OL</button>
<button type="button" on:click={() => applyWrap("`")}>Code</button>
</div>
{#if templateError}
<p class="template-error">{templateError}</p>
{/if}
{/if}
<div class="editor-workspace">
@ -190,6 +277,12 @@
color: var(--text-primary);
}
.template-error {
color: #e74c3c;
font-size: 0.78rem;
margin: -2px 0 0;
}
.editor-workspace {
min-height: 0;
height: 100%;

View File

@ -390,4 +390,16 @@
onCancel={handleModalCancel}
/>
<style>
.app-shell {
height: 100vh;
overflow: hidden;
}
.app-shell :global(.side-panel),
.app-shell :global(.editor-panel) {
min-height: 0;
min-width: 0;
}
</style>

View File

@ -2,6 +2,13 @@
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import {
deleteEntryTemplate,
listEntryTemplates,
loadEntryTemplate,
saveEntryTemplate,
type EntryTemplateItemDto
} from "$lib/backend/templates";
import {
addFragmentType,
addSettingsTag,
@ -34,6 +41,12 @@
let sidecarRoot = "";
let sidecarRootIsCustom = false;
let sidecarRootError = "";
let templates: EntryTemplateItemDto[] = [];
let templateName = "";
let templateContent = "";
let templateFilePath: string | null = null;
let templateError = "";
let templatesBusy = false;
onMount(async () => {
try {
@ -43,6 +56,8 @@
} catch (e) {
sidecarRootError = String(e);
}
await refreshTemplates();
});
async function saveSidecarRoot() {
@ -192,6 +207,84 @@
cancelEditFragmentType();
}
}
async function refreshTemplates() {
templatesBusy = true;
templateError = "";
try {
templates = await listEntryTemplates();
} catch (error) {
templateError = String(error);
} finally {
templatesBusy = false;
}
}
function resetTemplateEditor() {
templateName = "";
templateContent = "";
templateFilePath = null;
}
async function editTemplate(item: EntryTemplateItemDto) {
templateError = "";
try {
const loaded = await loadEntryTemplate(item.filePath);
templateFilePath = loaded.filePath;
templateName = loaded.fileName.replace(/\.template\.md$/i, "");
templateContent = loaded.content;
} catch (error) {
templateError = String(error);
}
}
async function saveTemplate() {
templateError = "";
if (!templateName.trim()) {
templateError = "Template name is required.";
return;
}
if (!templateContent.trim()) {
templateError = "Template content is required.";
return;
}
try {
const wasCreate = !templateFilePath;
const saved = await saveEntryTemplate({
name: templateName,
content: templateContent,
filePath: templateFilePath ?? undefined
});
if (wasCreate) {
resetTemplateEditor();
} else {
templateFilePath = saved.filePath;
}
await refreshTemplates();
} catch (error) {
templateError = String(error);
}
}
async function removeTemplate(item: EntryTemplateItemDto) {
templateError = "";
try {
const ok = await deleteEntryTemplate(item.filePath);
if (!ok) {
templateError = "Failed to delete template.";
return;
}
if (templateFilePath === item.filePath) {
resetTemplateEditor();
}
await refreshTemplates();
} catch (error) {
templateError = String(error);
}
}
</script>
<div class="app-shell panel-closed">
@ -205,16 +298,6 @@
<div class="settings-grid">
<section class="route-card">
<label class="toggle-row">
<input type="checkbox" checked />
<span>Launch to last opened entry</span>
</label>
<label class="toggle-row">
<input type="checkbox" />
<span>Enable compact editor mode</span>
</label>
<label>
Default startup view
<select>
@ -309,6 +392,57 @@
</ul>
</section>
<section class="route-card">
<h2>Entry Templates</h2>
<p class="section-copy">Create reusable markdown templates stored as <code>.template.md</code> files in your data directory.</p>
<div class="template-grid">
<div class="template-editor">
<input
type="text"
placeholder="Template name (example: Weekly Review)"
bind:value={templateName}
/>
<textarea
rows="8"
placeholder="Template markdown content"
bind:value={templateContent}
></textarea>
<div class="row-actions">
<button type="button" class="secondary-btn" on:click={saveTemplate}>
{templateFilePath ? "Update Template" : "Create Template"}
</button>
<button type="button" class="ghost-btn" on:click={resetTemplateEditor}>New</button>
<button type="button" class="ghost-btn" on:click={refreshTemplates}>Refresh</button>
</div>
</div>
<div class="template-list">
{#if templatesBusy}
<p class="section-copy">Loading templates...</p>
{:else if templates.length === 0}
<p class="section-copy">No templates found.</p>
{:else}
<ul class="item-list">
{#each templates as item}
<li class="item-row">
<span>{item.fileName.replace(/\.template\.md$/i, "")}</span>
<div class="row-actions">
<button type="button" class="ghost-btn" on:click={() => editTemplate(item)}>Edit</button>
<button type="button" class="danger-btn" on:click={() => removeTemplate(item)}>Delete</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if templateError}
<p class="error-text">{templateError}</p>
{/if}
</section>
<section class="route-card">
<h2>Sidecar</h2>
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
@ -384,14 +518,6 @@
margin-bottom: 16px;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 0.88rem;
}
.route-card label {
display: flex;
flex-direction: column;
@ -499,6 +625,31 @@
color: #e74c3c;
font-size: 0.82rem;
}
.template-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.template-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.template-editor textarea {
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--bg-app);
color: var(--text-primary);
padding: 8px 9px;
resize: vertical;
}
@media (min-width: 980px) {
.template-grid {
grid-template-columns: 1.2fr 1fr;
}
}
</style>

View File

@ -10,6 +10,11 @@ public sealed record EntryListItem(string FileName, string FilePath);
public sealed record EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry);
public sealed record EntrySaveResult(string FilePath);
internal sealed record EntryDeletePayload(string FilePath);
internal sealed record EntryTemplateListPayload(string? DataDirectory = null);
internal sealed record EntryTemplateLoadPayload(string FilePath);
internal sealed record EntryTemplateDeletePayload(string FilePath);
public sealed record EntryTemplateLoadResult(string FileName, string FilePath, string Content);
public sealed record EntryTemplateSavePayload(string Name, string Content, string? FilePath = null, string? DataDirectory = null);
internal sealed record DatabasePayload(string Password, string? DataDirectory = null);
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
internal sealed record AiSummarizeAllPayload(List<string>? Entries);

View File

@ -216,24 +216,49 @@ public class Entry(
: _config.Current.DataDirectory;
result = _entryFiles.ListEntries(listDataDirectory);
break;
case "templates.list":
var templateListPayload = DeserializePayload<EntryTemplateListPayload>(cmd.Payload);
var templateListDirectory = !string.IsNullOrWhiteSpace(templateListPayload?.DataDirectory)
? templateListPayload.DataDirectory
: _config.Current.DataDirectory;
result = _entryFiles.ListTemplates(templateListDirectory);
break;
case "entries.load":
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
break;
case "templates.load":
var loadTemplatePayload = DeserializePayload<EntryTemplateLoadPayload>(cmd.Payload);
if (loadTemplatePayload is null || string.IsNullOrWhiteSpace(loadTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadTemplate(loadTemplatePayload.FilePath);
break;
case "entries.save":
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
return Error("Missing or invalid payload");
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
break;
case "templates.save":
var saveTemplatePayload = DeserializePayload<EntryTemplateSavePayload>(cmd.Payload);
if (saveTemplatePayload is null || string.IsNullOrWhiteSpace(saveTemplatePayload.Name))
return Error("Missing or invalid payload");
result = _entryFiles.SaveTemplate(saveTemplatePayload, _config.Current.DataDirectory);
break;
case "entries.delete":
var deleteEntryPayload = DeserializePayload<EntryDeletePayload>(cmd.Payload);
if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath);
break;
case "templates.delete":
var deleteTemplatePayload = DeserializePayload<EntryTemplateDeletePayload>(cmd.Payload);
if (deleteTemplatePayload is null || string.IsNullOrWhiteSpace(deleteTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteTemplate(deleteTemplatePayload.FilePath);
break;
case "config.get":
result = _config.Current;
break;

View File

@ -0,0 +1,9 @@
namespace Journal.Core.Services.Entries;
internal static class EntryFileNaming
{
internal const string TemplateSuffix = ".template.md";
internal static bool IsTemplateFileName(string fileName)
=> fileName.EndsWith(TemplateSuffix, StringComparison.OrdinalIgnoreCase);
}

View File

@ -10,6 +10,16 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
public IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
{
return [.. _repo.ListMarkdownFiles(dataDirectory)
.Where(path => !EntryFileNaming.IsTemplateFileName(_repo.GetFileName(path)))
.Select(path => new EntryListItem(
FileName: _repo.GetFileName(path),
FilePath: _repo.GetFullPath(path)))];
}
public IReadOnlyList<EntryListItem> ListTemplates(string dataDirectory)
{
return [.. _repo.ListMarkdownFiles(dataDirectory)
.Where(path => EntryFileNaming.IsTemplateFileName(_repo.GetFileName(path)))
.Select(path => new EntryListItem(
FileName: _repo.GetFileName(path),
FilePath: _repo.GetFullPath(path)))];
@ -31,6 +41,20 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
Entry: entry.ToDto());
}
public EntryTemplateLoadResult LoadTemplate(string filePath)
{
var normalizedPath = _repo.GetFullPath(filePath);
if (!_repo.FileExists(normalizedPath))
throw new FileNotFoundException($"Template file not found: {normalizedPath}");
var fileName = _repo.GetFileName(normalizedPath);
if (!EntryFileNaming.IsTemplateFileName(fileName))
throw new ArgumentException("Template file name must end with .template.md.");
var rawContent = HtmlSanitizer.StripRichHtml(_repo.ReadFile(normalizedPath));
return new EntryTemplateLoadResult(fileName, normalizedPath, rawContent);
}
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
{
var targetPath = ResolveTargetPath(payload.FilePath, payload.FileName, defaultDataDirectory);
@ -69,6 +93,26 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
return new EntrySaveResult(targetPath);
}
public EntrySaveResult SaveTemplate(EntryTemplateSavePayload payload, string defaultDataDirectory)
{
ArgumentNullException.ThrowIfNull(payload);
if (string.IsNullOrWhiteSpace(payload.Name))
throw new ArgumentException("Template name is required.");
var directory = string.IsNullOrWhiteSpace(payload.DataDirectory)
? defaultDataDirectory
: payload.DataDirectory;
var targetPath = ResolveTemplatePath(payload.FilePath, payload.Name, directory);
var fileName = _repo.GetFileName(targetPath);
if (!EntryFileNaming.IsTemplateFileName(fileName))
throw new ArgumentException("Template file name must end with .template.md.");
var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? "");
_repo.EnsureDirectory(targetPath);
_repo.WriteFile(targetPath, sanitizedContent);
return new EntrySaveResult(targetPath);
}
public bool DeleteEntry(string filePath)
{
var normalizedPath = _repo.GetFullPath(filePath);
@ -78,6 +122,20 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
return true;
}
public bool DeleteTemplate(string filePath)
{
var normalizedPath = _repo.GetFullPath(filePath);
if (!_repo.FileExists(normalizedPath))
return false;
var fileName = _repo.GetFileName(normalizedPath);
if (!EntryFileNaming.IsTemplateFileName(fileName))
return false;
_repo.DeleteFile(normalizedPath);
return true;
}
private string ResolveTargetPath(string? filePath, string? fileName, string defaultDataDirectory)
{
if (!string.IsNullOrWhiteSpace(filePath))
@ -90,6 +148,15 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{name}.md"));
}
private string ResolveTemplatePath(string? filePath, string templateName, string defaultDataDirectory)
{
if (!string.IsNullOrWhiteSpace(filePath))
return _repo.GetFullPath(filePath);
var name = SanitizeFileName(templateName);
return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{name}{EntryFileNaming.TemplateSuffix}"));
}
private static string SanitizeFileName(string name)
{
var trimmed = name.Trim();

View File

@ -36,6 +36,9 @@ public class EntrySearchService : IEntrySearchService
.OrderBy(Path.GetFileName, StringComparer.Ordinal))
{
var fileName = Path.GetFileName(filePath);
if (EntryFileNaming.IsTemplateFileName(fileName))
continue;
var fileStem = Path.GetFileNameWithoutExtension(filePath);
var rawContent = File.ReadAllText(filePath);
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);

View File

@ -5,7 +5,11 @@ namespace Journal.Core.Services.Entries;
public interface IEntryFileService
{
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
IReadOnlyList<EntryListItem> ListTemplates(string dataDirectory);
EntryLoadResult LoadEntry(string filePath);
EntryTemplateLoadResult LoadTemplate(string filePath);
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
EntrySaveResult SaveTemplate(EntryTemplateSavePayload payload, string defaultDataDirectory);
bool DeleteEntry(string filePath);
bool DeleteTemplate(string filePath);
}

View File

@ -118,7 +118,11 @@ public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchSe
}
var (_, dataDirectory) = ResolveDirectories(vaultOverride: null, options.DataDirectory);
if (!Directory.Exists(dataDirectory) || Directory.GetFiles(dataDirectory, "*.md").Length == 0)
var entryCount = Directory.Exists(dataDirectory)
? Directory.GetFiles(dataDirectory, "*.md")
.Count(path => !EntryFileNaming.IsTemplateFileName(Path.GetFileName(path)))
: 0;
if (entryCount == 0)
{
Console.WriteLine("No decrypted journal entries found. Please load the vault first: journal vault load");
return 0;

View File

@ -190,6 +190,98 @@ hello world
}
}
static async Task TestEntryTemplatesCrudExcludesFromEntriesListAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-template-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "daily entry");
var entry = NewEntry();
var saveRequest = JsonSerializer.Serialize(new
{
action = "templates.save",
payload = new
{
name = "Weekly Review",
content = "# Weekly Review\n\n## Wins\n- one",
dataDirectory = root
}
});
var saveResponse = await entry.HandleCommandAsync(saveRequest);
using var saveDoc = JsonDocument.Parse(saveResponse);
Assert(saveDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.save.");
var templatePath = saveDoc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? "";
Assert(templatePath.EndsWith(".template.md", StringComparison.OrdinalIgnoreCase), "Template file should end with .template.md.");
Assert(File.Exists(templatePath), "Template file should exist.");
var listTemplatesRequest = JsonSerializer.Serialize(new
{
action = "templates.list",
payload = new
{
dataDirectory = root
}
});
var listTemplatesResponse = await entry.HandleCommandAsync(listTemplatesRequest);
using var listTemplatesDoc = JsonDocument.Parse(listTemplatesResponse);
Assert(listTemplatesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.list.");
var templateItems = listTemplatesDoc.RootElement.GetProperty("data");
Assert(templateItems.GetArrayLength() == 1, "Expected one template in templates.list.");
Assert(templateItems[0].GetProperty("FileName").GetString() == "Weekly Review.template.md", "Template file name mismatch.");
var listEntriesRequest = JsonSerializer.Serialize(new
{
action = "entries.list",
payload = new
{
dataDirectory = root
}
});
var listEntriesResponse = await entry.HandleCommandAsync(listEntriesRequest);
using var listEntriesDoc = JsonDocument.Parse(listEntriesResponse);
Assert(listEntriesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list.");
var entryItems = listEntriesDoc.RootElement.GetProperty("data");
Assert(entryItems.GetArrayLength() == 1, "Expected entries.list to exclude template files.");
Assert(entryItems[0].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected only daily entry file in entries.list.");
var loadTemplateRequest = JsonSerializer.Serialize(new
{
action = "templates.load",
payload = new
{
filePath = templatePath
}
});
var loadTemplateResponse = await entry.HandleCommandAsync(loadTemplateRequest);
using var loadTemplateDoc = JsonDocument.Parse(loadTemplateResponse);
Assert(loadTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.load.");
var content = loadTemplateDoc.RootElement.GetProperty("data").GetProperty("Content").GetString() ?? "";
Assert(content.Contains("## Wins", StringComparison.Ordinal), "Expected template content in templates.load result.");
var deleteTemplateRequest = JsonSerializer.Serialize(new
{
action = "templates.delete",
payload = new
{
filePath = templatePath
}
});
var deleteTemplateResponse = await entry.HandleCommandAsync(deleteTemplateRequest);
using var deleteTemplateDoc = JsonDocument.Parse(deleteTemplateResponse);
Assert(deleteTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.delete.");
Assert(deleteTemplateDoc.RootElement.GetProperty("data").GetBoolean(), "Expected templates.delete to return true.");
Assert(!File.Exists(templatePath), "Template file should be deleted.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesMatchesRawContentAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));

View File

@ -39,6 +39,7 @@ internal static partial class Program
("Entry entries.save writes and merges content", TestEntryEntriesSaveMergeAsync),
("Entry entries.load returns raw content payload", TestEntryEntriesLoadAsync),
("Entry entries.list returns markdown files", TestEntryEntriesListAsync),
("Entry templates CRUD stores .template.md and entries.list excludes templates", TestEntryTemplatesCrudExcludesFromEntriesListAsync),
("Entry search.entries matches query against full raw content", TestEntrySearchEntriesMatchesRawContentAsync),
("Entry search.entries without query returns all markdown entries", TestEntrySearchEntriesWithoutQueryReturnsAllAsync),
("Entry search.entries applies date range filter", TestEntrySearchEntriesDateRangeFilterAsync),