feat(templates): add markdown templates and editor insertion flow
This commit is contained in:
parent
7c3161c61b
commit
64c06081f0
90
Journal.App/src/lib/backend/templates.ts
Normal file
90
Journal.App/src/lib/backend/templates.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
@ -54,6 +54,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-empty {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
9
Journal.Core/Services/Entries/EntryFileNaming.cs
Normal file
9
Journal.Core/Services/Entries/EntryFileNaming.cs
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user