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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-empty {
|
.editor-empty {
|
||||||
|
|||||||
@ -322,6 +322,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@ -388,6 +390,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { listEntryTemplates, loadEntryTemplate, type EntryTemplateItemDto } from "$lib/backend/templates";
|
||||||
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
|
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
export let openDocumentId = "";
|
export let openDocumentId = "";
|
||||||
export let openDocumentName = "";
|
export let openDocumentName = "";
|
||||||
@ -10,6 +12,10 @@
|
|||||||
let lastOpenDocumentId = openDocumentId;
|
let lastOpenDocumentId = openDocumentId;
|
||||||
export let previewOnly = true;
|
export let previewOnly = true;
|
||||||
let editorInput: HTMLTextAreaElement | null = null;
|
let editorInput: HTMLTextAreaElement | null = null;
|
||||||
|
let templateOptions: EntryTemplateItemDto[] = [];
|
||||||
|
let templatesBusy = false;
|
||||||
|
let templateError = "";
|
||||||
|
let templateRefreshRequested = false;
|
||||||
|
|
||||||
function updateDraft(value: string) {
|
function updateDraft(value: string) {
|
||||||
markdownText = value;
|
markdownText = value;
|
||||||
@ -61,10 +67,68 @@
|
|||||||
applyWrap("[", "](https://example.com)");
|
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) {
|
$: if (openDocumentId !== lastOpenDocumentId) {
|
||||||
markdownText = openDocumentContent;
|
markdownText = openDocumentContent;
|
||||||
lastOpenDocumentId = openDocumentId;
|
lastOpenDocumentId = openDocumentId;
|
||||||
}
|
}
|
||||||
|
$: isEntryDocument = openDocumentId.startsWith("entries/file/") || openDocumentId.startsWith("entries/draft-");
|
||||||
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
|
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
|
||||||
$: renderedHtml = renderMarkdown(markdownText);
|
$: renderedHtml = renderMarkdown(markdownText);
|
||||||
</script>
|
</script>
|
||||||
@ -94,6 +158,26 @@
|
|||||||
<option value="5">H5</option>
|
<option value="5">H5</option>
|
||||||
<option value="6">H6</option>
|
<option value="6">H6</option>
|
||||||
</select>
|
</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("**")}>Bold</button>
|
||||||
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
|
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
|
||||||
<button type="button" on:click={insertLink}>Link</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={() => applyLinePrefix("1. ")}>OL</button>
|
||||||
<button type="button" on:click={() => applyWrap("`")}>Code</button>
|
<button type="button" on:click={() => applyWrap("`")}>Code</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if templateError}
|
||||||
|
<p class="template-error">{templateError}</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="editor-workspace">
|
<div class="editor-workspace">
|
||||||
@ -190,6 +277,12 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-error {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin: -2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-workspace {
|
.editor-workspace {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@ -390,4 +390,16 @@
|
|||||||
onCancel={handleModalCancel}
|
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 { goto } from "$app/navigation";
|
||||||
import AppModal from "$lib/components/AppModal.svelte";
|
import AppModal from "$lib/components/AppModal.svelte";
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
|
import {
|
||||||
|
deleteEntryTemplate,
|
||||||
|
listEntryTemplates,
|
||||||
|
loadEntryTemplate,
|
||||||
|
saveEntryTemplate,
|
||||||
|
type EntryTemplateItemDto
|
||||||
|
} from "$lib/backend/templates";
|
||||||
import {
|
import {
|
||||||
addFragmentType,
|
addFragmentType,
|
||||||
addSettingsTag,
|
addSettingsTag,
|
||||||
@ -34,6 +41,12 @@
|
|||||||
let sidecarRoot = "";
|
let sidecarRoot = "";
|
||||||
let sidecarRootIsCustom = false;
|
let sidecarRootIsCustom = false;
|
||||||
let sidecarRootError = "";
|
let sidecarRootError = "";
|
||||||
|
let templates: EntryTemplateItemDto[] = [];
|
||||||
|
let templateName = "";
|
||||||
|
let templateContent = "";
|
||||||
|
let templateFilePath: string | null = null;
|
||||||
|
let templateError = "";
|
||||||
|
let templatesBusy = false;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
@ -43,6 +56,8 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
sidecarRootError = String(e);
|
sidecarRootError = String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshTemplates();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function saveSidecarRoot() {
|
async function saveSidecarRoot() {
|
||||||
@ -192,6 +207,84 @@
|
|||||||
cancelEditFragmentType();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="app-shell panel-closed">
|
<div class="app-shell panel-closed">
|
||||||
@ -205,16 +298,6 @@
|
|||||||
|
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<section class="route-card">
|
<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>
|
<label>
|
||||||
Default startup view
|
Default startup view
|
||||||
<select>
|
<select>
|
||||||
@ -309,6 +392,57 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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">
|
<section class="route-card">
|
||||||
<h2>Sidecar</h2>
|
<h2>Sidecar</h2>
|
||||||
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
|
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
|
||||||
@ -384,14 +518,6 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-card label {
|
.route-card label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -499,6 +625,31 @@
|
|||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
font-size: 0.82rem;
|
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>
|
</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 EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry);
|
||||||
public sealed record EntrySaveResult(string FilePath);
|
public sealed record EntrySaveResult(string FilePath);
|
||||||
internal sealed record EntryDeletePayload(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 DatabasePayload(string Password, string? DataDirectory = null);
|
||||||
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
||||||
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
|
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
|
||||||
|
|||||||
@ -216,24 +216,49 @@ public class Entry(
|
|||||||
: _config.Current.DataDirectory;
|
: _config.Current.DataDirectory;
|
||||||
result = _entryFiles.ListEntries(listDataDirectory);
|
result = _entryFiles.ListEntries(listDataDirectory);
|
||||||
break;
|
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":
|
case "entries.load":
|
||||||
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
|
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
|
||||||
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
|
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
|
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
|
||||||
break;
|
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":
|
case "entries.save":
|
||||||
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
|
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
|
||||||
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
|
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
||||||
break;
|
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":
|
case "entries.delete":
|
||||||
var deleteEntryPayload = DeserializePayload<EntryDeletePayload>(cmd.Payload);
|
var deleteEntryPayload = DeserializePayload<EntryDeletePayload>(cmd.Payload);
|
||||||
if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath))
|
if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath))
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath);
|
result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath);
|
||||||
break;
|
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":
|
case "config.get":
|
||||||
result = _config.Current;
|
result = _config.Current;
|
||||||
break;
|
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)
|
public IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
|
||||||
{
|
{
|
||||||
return [.. _repo.ListMarkdownFiles(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(
|
.Select(path => new EntryListItem(
|
||||||
FileName: _repo.GetFileName(path),
|
FileName: _repo.GetFileName(path),
|
||||||
FilePath: _repo.GetFullPath(path)))];
|
FilePath: _repo.GetFullPath(path)))];
|
||||||
@ -31,6 +41,20 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
|
|||||||
Entry: entry.ToDto());
|
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)
|
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
||||||
{
|
{
|
||||||
var targetPath = ResolveTargetPath(payload.FilePath, payload.FileName, defaultDataDirectory);
|
var targetPath = ResolveTargetPath(payload.FilePath, payload.FileName, defaultDataDirectory);
|
||||||
@ -69,6 +93,26 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
|
|||||||
return new EntrySaveResult(targetPath);
|
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)
|
public bool DeleteEntry(string filePath)
|
||||||
{
|
{
|
||||||
var normalizedPath = _repo.GetFullPath(filePath);
|
var normalizedPath = _repo.GetFullPath(filePath);
|
||||||
@ -78,6 +122,20 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
|
|||||||
return true;
|
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)
|
private string ResolveTargetPath(string? filePath, string? fileName, string defaultDataDirectory)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(filePath))
|
if (!string.IsNullOrWhiteSpace(filePath))
|
||||||
@ -90,6 +148,15 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
|
|||||||
return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{name}.md"));
|
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)
|
private static string SanitizeFileName(string name)
|
||||||
{
|
{
|
||||||
var trimmed = name.Trim();
|
var trimmed = name.Trim();
|
||||||
|
|||||||
@ -36,6 +36,9 @@ public class EntrySearchService : IEntrySearchService
|
|||||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal))
|
.OrderBy(Path.GetFileName, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
var fileName = Path.GetFileName(filePath);
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
if (EntryFileNaming.IsTemplateFileName(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
var fileStem = Path.GetFileNameWithoutExtension(filePath);
|
var fileStem = Path.GetFileNameWithoutExtension(filePath);
|
||||||
var rawContent = File.ReadAllText(filePath);
|
var rawContent = File.ReadAllText(filePath);
|
||||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||||
|
|||||||
@ -5,7 +5,11 @@ namespace Journal.Core.Services.Entries;
|
|||||||
public interface IEntryFileService
|
public interface IEntryFileService
|
||||||
{
|
{
|
||||||
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
|
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
|
||||||
|
IReadOnlyList<EntryListItem> ListTemplates(string dataDirectory);
|
||||||
EntryLoadResult LoadEntry(string filePath);
|
EntryLoadResult LoadEntry(string filePath);
|
||||||
|
EntryTemplateLoadResult LoadTemplate(string filePath);
|
||||||
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
|
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
|
||||||
|
EntrySaveResult SaveTemplate(EntryTemplateSavePayload payload, string defaultDataDirectory);
|
||||||
bool DeleteEntry(string filePath);
|
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);
|
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");
|
Console.WriteLine("No decrypted journal entries found. Please load the vault first: journal vault load");
|
||||||
return 0;
|
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()
|
static async Task TestEntrySearchEntriesMatchesRawContentAsync()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
|
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.save writes and merges content", TestEntryEntriesSaveMergeAsync),
|
||||||
("Entry entries.load returns raw content payload", TestEntryEntriesLoadAsync),
|
("Entry entries.load returns raw content payload", TestEntryEntriesLoadAsync),
|
||||||
("Entry entries.list returns markdown files", TestEntryEntriesListAsync),
|
("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 matches query against full raw content", TestEntrySearchEntriesMatchesRawContentAsync),
|
||||||
("Entry search.entries without query returns all markdown entries", TestEntrySearchEntriesWithoutQueryReturnsAllAsync),
|
("Entry search.entries without query returns all markdown entries", TestEntrySearchEntriesWithoutQueryReturnsAllAsync),
|
||||||
("Entry search.entries applies date range filter", TestEntrySearchEntriesDateRangeFilterAsync),
|
("Entry search.entries applies date range filter", TestEntrySearchEntriesDateRangeFilterAsync),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user