Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3399,34 +3399,45 @@ function registerIpc(): void {
return await deleteWorkflowRuns(v.root, workflowId);
});

// Custom templates live on the local filesystem only; remote vaults fall
// back to built-in templates (renderer constants), so list returns empty and
// mutations are rejected.
const requireRemoteTemplates = () => {
const client = requireRemoteWorkspaceClient();
if (!remoteServerCapabilities?.supportsCustomTemplates) {
throw new Error(
"Custom templates need a newer ZenNotes server. Update the server and reconnect.",
);
}
return client;
};

handle(IPC.VAULT_LIST_TEMPLATES, async () => {
if (isRemoteWorkspaceActive()) return [];
if (isRemoteWorkspaceActive()) {
return remoteServerCapabilities?.supportsCustomTemplates
? await requireRemoteWorkspaceClient().listTemplates()
: [];
}
const v = requireVault();
return await listCustomTemplates(v.root);
});

handle(IPC.VAULT_READ_TEMPLATE, async (_e, sourcePath: string) => {
if (isRemoteWorkspaceActive()) {
throw new Error("Custom templates are unavailable on remote vaults");
return await requireRemoteTemplates().readTemplate(sourcePath);
}
const v = requireVault();
return await readCustomTemplate(v.root, sourcePath);
});

handle(IPC.VAULT_WRITE_TEMPLATE, async (_e, input: WriteTemplateInput) => {
if (isRemoteWorkspaceActive()) {
throw new Error("Custom templates are unavailable on remote vaults");
return await requireRemoteTemplates().writeTemplate(input);
}
const v = requireVault();
return await writeCustomTemplate(v.root, input);
});

handle(IPC.VAULT_DELETE_TEMPLATE, async (_e, sourcePath: string) => {
if (isRemoteWorkspaceActive()) {
throw new Error("Custom templates are unavailable on remote vaults");
return await requireRemoteTemplates().deleteTemplate(sourcePath);
}
const v = requireVault();
return await deleteCustomTemplate(v.root, sourcePath);
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/remote/server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import type {
VaultTextSearchToolPaths
} from '@shared/ipc'
import type { VaultTask } from '@shared/tasks'
import type {
CustomTemplateFile,
WriteTemplateInput
} from '@zennotes/bridge-contract/templates'
import WebSocket from 'ws'
import {
connectionErrorMessage,
Expand Down Expand Up @@ -201,6 +205,28 @@ export class RemoteServerClient {
})
}

async listTemplates(): Promise<CustomTemplateFile[]> {
return this.jsonRequest<CustomTemplateFile[]>('/api/templates')
}

async readTemplate(sourcePath: string): Promise<string> {
const result = await this.jsonRequest<{ raw: string }>(
`/api/templates/read?path=${encodeURIComponent(sourcePath)}`
)
return result.raw
}

async writeTemplate(input: WriteTemplateInput): Promise<CustomTemplateFile> {
return this.jsonRequest<CustomTemplateFile>('/api/templates/write', {
method: 'POST',
body: input as unknown as Record<string, unknown>
})
}

async deleteTemplate(sourcePath: string): Promise<void> {
await this.jsonRequest('/api/templates/delete', { method: 'POST', body: { sourcePath } })
}

async readNote(relPath: string): Promise<NoteContent> {
return this.jsonRequest<NoteContent>(`/api/notes/read?path=${encodeURIComponent(relPath)}`)
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ function safeSlug(slug: string): string {
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/-+$/g, '')
return cleaned || 'template'
}

Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/main/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,26 @@ describe('VaultWatcher atomic saves', () => {
20_000
)
})

describe('VaultWatcher custom templates', () => {
it(
'reports template changes from the hidden vault directory',
async () => {
const root = await makeVault()
const events: VaultChangeEvent[] = []
const watcher = new VaultWatcher()
watchers.push(watcher)
watcher.start(root, (event) => events.push(event))
await sleep(400)

const template = path.join(root, '.zennotes', 'templates', 'standup.md')
await mkdir(path.dirname(template), { recursive: true })
await writeFile(template, '# Standup\n')

const event = await waitForEvent(events)
expect(event?.path).toBe('.zennotes/templates/standup.md')
expect(event?.scope).toBe('templates')
},
20_000
)
})
6 changes: 6 additions & 0 deletions apps/desktop/src/main/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const INTERNAL_VAULT_DIR = '.zennotes'
const VAULT_SETTINGS_RELATIVE_PATH = `${INTERNAL_VAULT_DIR}/vault.json`
const NOTE_COMMENTS_PREFIX = `${INTERNAL_VAULT_DIR}/comments/`
const NOTE_COMMENTS_SUFFIX = '.comments.json'
const TEMPLATES_PREFIX = `${INTERNAL_VAULT_DIR}/templates/`

function toPosix(p: string): string {
return p.split(path.sep).join('/')
Expand Down Expand Up @@ -129,6 +130,11 @@ export class VaultWatcher {
)
return
}
const rel = relativeVaultPath(this.root, absPath)
if (rel.startsWith(TEMPLATES_PREFIX) && rel.toLowerCase().endsWith('.md')) {
onEvent({ kind, path: rel, folder: 'inbox', scope: 'templates' })
return
}
// Any database file — `<Name>.base/data.csv` or `schema.json` (or a legacy
// loose `.csv`/sidecar) — normalizes to the canonical `data.csv` path so
// the renderer re-hydrates the right database. (Record-page `.md` notes in
Expand Down
12 changes: 12 additions & 0 deletions apps/server/internal/httpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ func (s *Server) registerProtectedRoutes(r chi.Router) {
r.Get("/workflows/runs", s.listWorkflowRuns)
r.Post("/workflows/runs/delete", s.deleteWorkflowRuns)

r.Get("/templates", s.listTemplates)
r.Get("/templates/read", s.readTemplate)
r.Post("/templates/write", s.writeTemplate)
r.Post("/templates/delete", s.deleteTemplate)

r.Get("/watch", s.watchWS)
}

Expand Down Expand Up @@ -301,6 +306,10 @@ func writeError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, vault.ErrInvalidTemplate) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, vault.ErrWorkflowConflict) {
http.Error(w, err.Error(), http.StatusConflict)
return
Expand Down Expand Up @@ -415,6 +424,9 @@ func (s *Server) capabilities(w http.ResponseWriter, _ *http.Request) {
// prepared-run endpoint applies them under the same vault lock as note
// writes. Its presence lets bundled web clients enable authoring and Run.
"supportsWorkflows": true,
// Custom template files use the same mounted-vault ownership model as
// workflows; clients can safely enable authoring when this is present.
"supportsCustomTemplates": true,
// Says out loud that a missing file answers 404 rather than 500.
// Databases are composed from file reads where "absent" and "failed"
// mean opposite things (see remote-absence.ts), and a server that
Expand Down
61 changes: 61 additions & 0 deletions apps/server/internal/httpserver/templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package httpserver

import (
"net/http"

"github.com/ZenNotes/zennotes/apps/server/internal/vault"
)

func (s *Server) listTemplates(w http.ResponseWriter, _ *http.Request) {
files, err := s.currentVault().ListTemplates()
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, files)
}

func (s *Server) readTemplate(w http.ResponseWriter, r *http.Request) {
raw, err := s.currentVault().ReadTemplate(r.URL.Query().Get("path"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"raw": raw})
}

func (s *Server) writeTemplate(w http.ResponseWriter, r *http.Request) {
cfg := s.currentConfig()
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes)
var input vault.WriteTemplateInput
if err := readJSON(r, &input); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if cfg.MaxNoteBytes > 0 && int64(len(input.Raw)) > cfg.MaxNoteBytes {
http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge)
return
}
file, err := s.currentVault().WriteTemplate(input)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, file)
}

func (s *Server) deleteTemplate(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, jsonEnvelopeBytes)
var request struct {
SourcePath string `json:"sourcePath"`
}
if err := readJSON(r, &request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.currentVault().DeleteTemplate(request.SourcePath); err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
Loading