From e2bb6aaa60e189a1fcf651a5c52ae9dd01c355a2 Mon Sep 17 00:00:00 2001 From: Ajsel Date: Wed, 2 Sep 2026 16:32:02 +0200 Subject: [PATCH] Feat(templates): support custom templates in remote vaults --- apps/desktop/src/main/index.ts | 25 ++- apps/desktop/src/main/remote/server-client.ts | 26 +++ apps/desktop/src/main/templates.ts | 2 + apps/desktop/src/main/watcher.test.ts | 23 +++ apps/desktop/src/main/watcher.ts | 6 + apps/server/internal/httpserver/server.go | 12 ++ apps/server/internal/httpserver/templates.go | 61 ++++++ .../internal/httpserver/templates_test.go | 186 ++++++++++++++++++ apps/server/internal/vault/templates.go | 168 ++++++++++++++++ apps/server/internal/vault/vault.go | 5 + apps/server/internal/watcher/watcher.go | 10 + apps/server/internal/watcher/watcher_test.go | 25 +++ apps/web/src/bridge/http-bridge.ts | 48 +++-- .../connect-desktop-to-remote-server.md | 2 + docs/how-to/self-host-with-docker.md | 4 +- docs/reference/security-reference.md | 4 +- .../src/components/SettingsModal.test.ts | 50 +++++ .../app-core/src/components/SettingsModal.tsx | 7 +- packages/app-core/src/lib/help.ts | 2 +- packages/app-core/src/store.test.ts | 21 ++ packages/app-core/src/store.ts | 5 + packages/bridge-contract/src/bridge.ts | 2 +- packages/bridge-contract/src/ipc.ts | 3 + 23 files changed, 666 insertions(+), 31 deletions(-) create mode 100644 apps/server/internal/httpserver/templates.go create mode 100644 apps/server/internal/httpserver/templates_test.go create mode 100644 apps/server/internal/vault/templates.go diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1b25fdff..0a334c93 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3250,18 +3250,29 @@ 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); @@ -3269,7 +3280,7 @@ function registerIpc(): void { 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); @@ -3277,7 +3288,7 @@ function registerIpc(): void { 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); diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index 9fd43d12..cf113b3d 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -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, @@ -201,6 +205,28 @@ export class RemoteServerClient { }) } + async listTemplates(): Promise { + return this.jsonRequest('/api/templates') + } + + async readTemplate(sourcePath: string): Promise { + const result = await this.jsonRequest<{ raw: string }>( + `/api/templates/read?path=${encodeURIComponent(sourcePath)}` + ) + return result.raw + } + + async writeTemplate(input: WriteTemplateInput): Promise { + return this.jsonRequest('/api/templates/write', { + method: 'POST', + body: input as unknown as Record + }) + } + + async deleteTemplate(sourcePath: string): Promise { + await this.jsonRequest('/api/templates/delete', { method: 'POST', body: { sourcePath } }) + } + async readNote(relPath: string): Promise { return this.jsonRequest(`/api/notes/read?path=${encodeURIComponent(relPath)}`) } diff --git a/apps/desktop/src/main/templates.ts b/apps/desktop/src/main/templates.ts index debe73ad..9ebfe212 100644 --- a/apps/desktop/src/main/templates.ts +++ b/apps/desktop/src/main/templates.ts @@ -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' } diff --git a/apps/desktop/src/main/watcher.test.ts b/apps/desktop/src/main/watcher.test.ts index 5ffdcd28..883f9bf6 100644 --- a/apps/desktop/src/main/watcher.test.ts +++ b/apps/desktop/src/main/watcher.test.ts @@ -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 + ) +}) diff --git a/apps/desktop/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts index 549f9f01..1de05789 100644 --- a/apps/desktop/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -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('/') @@ -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 — `.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 diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 0fc1d558..87238820 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -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) } @@ -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 @@ -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 diff --git a/apps/server/internal/httpserver/templates.go b/apps/server/internal/httpserver/templates.go new file mode 100644 index 00000000..f5df681b --- /dev/null +++ b/apps/server/internal/httpserver/templates.go @@ -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}) +} diff --git a/apps/server/internal/httpserver/templates_test.go b/apps/server/internal/httpserver/templates_test.go new file mode 100644 index 00000000..86f6eec5 --- /dev/null +++ b/apps/server/internal/httpserver/templates_test.go @@ -0,0 +1,186 @@ +package httpserver + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" +) + +func TestTemplateEndpointsPersistRemoteCRUD(t *testing.T) { + root := t.TempDir() + server, _ := newTestServer(t, config.Config{ + VaultPath: root, DefaultVaultPath: root, Bind: "127.0.0.1:7878", + AuthToken: "secret-token", BrowseRoots: []string{root}, + }) + unauthenticatedResp, err := http.Get(server.URL + "/api/templates") + if err != nil { + t.Fatal(err) + } + unauthenticatedResp.Body.Close() + if unauthenticatedResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated list: got %d, want 401", unauthenticatedResp.StatusCode) + } + + client := &http.Client{Jar: loginAndJar(t, server, "secret-token")} + capsResp, err := client.Get(server.URL + "/api/capabilities") + if err != nil { + t.Fatal(err) + } + var caps map[string]any + if err := json.NewDecoder(capsResp.Body).Decode(&caps); err != nil { + t.Fatal(err) + } + capsResp.Body.Close() + if caps["supportsCustomTemplates"] != true { + t.Fatalf("supportsCustomTemplates = %v, want true", caps["supportsCustomTemplates"]) + } + + post := func(path string, payload any) *http.Response { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + return resp + } + requireOK := func(resp *http.Response) { + t.Helper() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("%s: got %d: %s", resp.Request.URL.Path, resp.StatusCode, body) + } + } + + raw := "---\nname: Standup\n---\n# {{date}}\n" + writeResp := post("/api/templates/write", map[string]string{"slug": "Daily--Standup", "raw": raw}) + requireOK(writeResp) + var written struct { + SourcePath string `json:"sourcePath"` + Raw string `json:"raw"` + } + if err := json.NewDecoder(writeResp.Body).Decode(&written); err != nil { + t.Fatal(err) + } + writeResp.Body.Close() + if written.SourcePath != ".zennotes/templates/daily--standup.md" || written.Raw != raw { + t.Fatalf("written template = %+v", written) + } + + readResp, err := client.Get(server.URL + "/api/templates/read?path=" + url.QueryEscape(written.SourcePath)) + if err != nil { + t.Fatal(err) + } + requireOK(readResp) + var read map[string]string + if err := json.NewDecoder(readResp.Body).Decode(&read); err != nil { + t.Fatal(err) + } + readResp.Body.Close() + if read["raw"] != raw { + t.Fatalf("read template = %q", read["raw"]) + } + + listResp, err := client.Get(server.URL + "/api/templates") + if err != nil { + t.Fatal(err) + } + requireOK(listResp) + var listed []map[string]string + if err := json.NewDecoder(listResp.Body).Decode(&listed); err != nil { + t.Fatal(err) + } + listResp.Body.Close() + if len(listed) != 1 || listed[0]["sourcePath"] != written.SourcePath { + t.Fatalf("listed templates = %#v", listed) + } + duplicateResp := post("/api/templates/write", map[string]string{"slug": "Daily--Standup", "raw": raw}) + requireOK(duplicateResp) + var duplicate map[string]string + if err := json.NewDecoder(duplicateResp.Body).Decode(&duplicate); err != nil { + t.Fatal(err) + } + duplicateResp.Body.Close() + if duplicate["sourcePath"] != ".zennotes/templates/daily--standup-2.md" { + t.Fatalf("duplicate template = %#v", duplicate) + } + longSlugResp := post("/api/templates/write", map[string]string{"slug": strings.Repeat("a", 1000), "raw": raw}) + requireOK(longSlugResp) + var longSlug map[string]string + if err := json.NewDecoder(longSlugResp.Body).Decode(&longSlug); err != nil { + t.Fatal(err) + } + longSlugResp.Body.Close() + if longSlug["sourcePath"] != ".zennotes/templates/"+strings.Repeat("a", 64)+".md" { + t.Fatalf("long-slug template = %#v", longSlug) + } + trimmedSlugResp := post("/api/templates/write", map[string]string{"slug": strings.Repeat("-", 100) + "meaningful", "raw": raw}) + requireOK(trimmedSlugResp) + var trimmedSlug map[string]string + if err := json.NewDecoder(trimmedSlugResp.Body).Decode(&trimmedSlug); err != nil { + t.Fatal(err) + } + trimmedSlugResp.Body.Close() + if trimmedSlug["sourcePath"] != ".zennotes/templates/meaningful.md" { + t.Fatalf("trimmed-slug template = %#v", trimmedSlug) + } + + renameResp := post("/api/templates/write", map[string]string{ + "slug": "Team Standup", "raw": raw, "previousSourcePath": written.SourcePath, + }) + requireOK(renameResp) + var renamed map[string]string + if err := json.NewDecoder(renameResp.Body).Decode(&renamed); err != nil { + t.Fatal(err) + } + renameResp.Body.Close() + if renamed["sourcePath"] != ".zennotes/templates/team-standup.md" { + t.Fatalf("renamed template = %#v", renamed) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "daily--standup.md")); !os.IsNotExist(err) { + t.Fatalf("old template still exists: %v", err) + } + + badResp := post("/api/templates/delete", map[string]string{"sourcePath": ".zennotes/templates/../../outside.md"}) + if badResp.StatusCode != http.StatusBadRequest { + t.Fatalf("traversal delete: got %d, want 400", badResp.StatusCode) + } + badResp.Body.Close() + + deleteResp := post("/api/templates/delete", map[string]string{"sourcePath": renamed["sourcePath"]}) + requireOK(deleteResp) + deleteResp.Body.Close() +} + +func TestTemplateWriteHonorsMaxNoteBytes(t *testing.T) { + root := t.TempDir() + server, _ := newTestServer(t, config.Config{ + VaultPath: root, DefaultVaultPath: root, Bind: "127.0.0.1:7878", + AuthToken: "secret-token", BrowseRoots: []string{root}, MaxNoteBytes: 8, + }) + client := &http.Client{Jar: loginAndJar(t, server, "secret-token")} + body, err := json.Marshal(map[string]string{"slug": "too-large", "raw": "123456789"}) + if err != nil { + t.Fatal(err) + } + resp, err := client.Post(server.URL+"/api/templates/write", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized template: got %d, want 413", resp.StatusCode) + } +} diff --git a/apps/server/internal/vault/templates.go b/apps/server/internal/vault/templates.go new file mode 100644 index 00000000..6c5bd2be --- /dev/null +++ b/apps/server/internal/vault/templates.go @@ -0,0 +1,168 @@ +package vault + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const ( + templatesRelDir = ".zennotes/templates" + maxTemplateSlugLength = 64 +) + +var ( + ErrInvalidTemplate = errors.New("invalid template request") + unsafeTemplateSlugChars = regexp.MustCompile(`[^a-z0-9-]+`) +) + +type CustomTemplateFile struct { + SourcePath string `json:"sourcePath"` + Raw string `json:"raw"` +} + +type WriteTemplateInput struct { + Slug string `json:"slug"` + Raw string `json:"raw"` + PreviousSourcePath string `json:"previousSourcePath,omitempty"` +} + +func templateDir(root string) string { + return filepath.Join(root, ".zennotes", "templates") +} + +func safeTemplateSlug(value string) string { + cleaned := strings.Trim(unsafeTemplateSlugChars.ReplaceAllString(strings.ToLower(value), "-"), "-") + if len(cleaned) > maxTemplateSlugLength { + cleaned = strings.TrimRight(cleaned[:maxTemplateSlugLength], "-") + } + if cleaned != "" { + return cleaned + } + return "template" +} + +func (v *Vault) resolveTemplateFilePath(sourcePath string) (string, error) { + abs, err := SafeJoin(v.root, sourcePath) + if err != nil { + return "", err + } + rel, err := filepath.Rel(templateDir(v.root), abs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { + return "", fmt.Errorf("%w: refusing template path outside templates dir", ErrInvalidTemplate) + } + if !strings.EqualFold(filepath.Ext(rel), ".md") { + return "", fmt.Errorf("%w: template path must be a .md file", ErrInvalidTemplate) + } + return abs, nil +} + +func (v *Vault) ListTemplates() ([]CustomTemplateFile, error) { + v.mu.RLock() + defer v.mu.RUnlock() + entries, err := os.ReadDir(templateDir(v.root)) + if errors.Is(err, os.ErrNotExist) { + return []CustomTemplateFile{}, nil + } + if err != nil { + return nil, err + } + files := make([]CustomTemplateFile, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { + continue + } + sourcePath := templatesRelDir + "/" + name + abs, err := v.resolveTemplateFilePath(sourcePath) + if err != nil { + continue + } + raw, err := os.ReadFile(abs) + if err != nil { + continue + } + files = append(files, CustomTemplateFile{SourcePath: sourcePath, Raw: string(raw)}) + } + sort.Slice(files, func(i, j int) bool { return files[i].SourcePath < files[j].SourcePath }) + return files, nil +} + +func (v *Vault) ReadTemplate(sourcePath string) (string, error) { + v.mu.RLock() + defer v.mu.RUnlock() + abs, err := v.resolveTemplateFilePath(sourcePath) + if err != nil { + return "", err + } + raw, err := os.ReadFile(abs) + return string(raw), err +} + +func (v *Vault) WriteTemplate(input WriteTemplateInput) (CustomTemplateFile, error) { + v.mu.Lock() + defer v.mu.Unlock() + dir := templateDir(v.root) + base := safeTemplateSlug(input.Slug) + previousStem := strings.TrimSuffix(filepath.Base(input.PreviousSourcePath), filepath.Ext(input.PreviousSourcePath)) + slug := base + for suffix := 2; ; suffix++ { + if slug == previousStem { + break + } + _, err := os.Stat(filepath.Join(dir, slug+".md")) + if errors.Is(err, os.ErrNotExist) { + break + } + if err != nil { + return CustomTemplateFile{}, err + } + slug = fmt.Sprintf("%s-%d", base, suffix) + } + sourcePath := templatesRelDir + "/" + slug + ".md" + abs, err := v.resolveTemplateFilePath(sourcePath) + if err != nil { + return CustomTemplateFile{}, err + } + var previous string + if input.PreviousSourcePath != "" { + previous, err = v.resolveTemplateFilePath(input.PreviousSourcePath) + if err != nil { + return CustomTemplateFile{}, err + } + } + if err := writeFileAtomic(abs, []byte(input.Raw), v.fileMode, v.dirMode); err != nil { + return CustomTemplateFile{}, err + } + if previous != "" && previous != abs { + sameFile := false + if prevInfo, statErr := os.Stat(previous); statErr == nil { + if newInfo, statErr := os.Stat(abs); statErr == nil && os.SameFile(prevInfo, newInfo) { + sameFile = true + } + } + if !sameFile { + if err := os.Remove(previous); err != nil && !errors.Is(err, os.ErrNotExist) { + return CustomTemplateFile{}, err + } + } + } + return CustomTemplateFile{SourcePath: sourcePath, Raw: input.Raw}, nil +} + +func (v *Vault) DeleteTemplate(sourcePath string) error { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := v.resolveTemplateFilePath(sourcePath) + if err != nil { + return err + } + if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index 648f0e12..e06e2b25 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -303,6 +303,11 @@ func New(root string, opts Options) (*Vault, error) { if err := v.EnsureLayout(); err != nil { return nil, err } + // The server watcher starts after New; create template storage now so it + // cannot miss the first file while discovering a newly-created directory. + if err := os.MkdirAll(templateDir(v.root), v.dirMode); err != nil { + return nil, err + } return v, nil } diff --git a/apps/server/internal/watcher/watcher.go b/apps/server/internal/watcher/watcher.go index 4b4ed1e2..6f77aeef 100644 --- a/apps/server/internal/watcher/watcher.go +++ b/apps/server/internal/watcher/watcher.go @@ -17,6 +17,7 @@ const ( vaultSettingsFilePath = ".zennotes/vault.json" noteCommentsPrefix = ".zennotes/comments/" noteCommentsSuffix = ".comments.json" + templatesPrefix = ".zennotes/templates/" ) // Watcher recursively watches the vault root and fans out change @@ -310,6 +311,15 @@ func (w *Watcher) handle(ev fsnotify.Event) { }) return } + if strings.HasPrefix(relPosix, templatesPrefix) && strings.EqualFold(filepath.Ext(relPosix), ".md") { + kind := eventKind(ev, statErr == nil) + if kind != "" { + w.broadcast(vault.ChangeEvent{ + Kind: kind, Path: relPosix, Folder: vault.FolderInbox, Scope: "templates", + }) + } + return + } if strings.HasPrefix(relPosix, ".") || strings.Contains(relPosix, "/.") { return } diff --git a/apps/server/internal/watcher/watcher_test.go b/apps/server/internal/watcher/watcher_test.go index 7f9f415f..7374c60a 100644 --- a/apps/server/internal/watcher/watcher_test.go +++ b/apps/server/internal/watcher/watcher_test.go @@ -205,6 +205,31 @@ func TestWatcherDoesNotSurfaceInternalDirAsFolder(t *testing.T) { } } +func TestWatcherReportsFirstTemplateWrite(t *testing.T) { + root := t.TempDir() + v, err := vault.New(root, vault.Options{}) + if err != nil { + t.Fatal(err) + } + w, err := Start(v.Root()) + if err != nil { + t.Skipf("fsnotify unavailable: %v", err) + } + defer w.Close() + ch, unsub := w.Subscribe() + defer unsub() + + template := filepath.Join(root, ".zennotes", "templates", "standup.md") + if err := os.WriteFile(template, []byte("# Standup"), 0o600); err != nil { + t.Fatal(err) + } + + ev := recvChange(t, ch) + if ev.Scope != "templates" { + t.Fatalf("first template event = %+v, want template-scoped reload", ev) + } +} + func TestActiveDistinguishesRealFromDisabledWatcher(t *testing.T) { root := t.TempDir() diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 06ffaa52..5860477c 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -795,23 +795,42 @@ async function deleteWorkflowRuns(workflowId: string): Promise { }) } -// Custom templates require local-filesystem CRUD, which the web app does not -// have (supportsCustomTemplates is false). Built-in templates still work since -// they are renderer constants. List is empty; mutations are rejected. -function listTemplates(): Promise { - return Promise.resolve([]) +async function serverSupportsCustomTemplates(): Promise { + const capabilities = lastServerCapabilities ?? (await getServerCapabilities()) + return capabilities?.supportsCustomTemplates === true +} + +async function requireServerTemplateSupport(): Promise { + if (await serverSupportsCustomTemplates()) return + throw new Error( + 'This ZenNotes server does not support custom templates yet. Update the server and reload.' + ) } -function readTemplate(_sourcePath: string): Promise { - return Promise.reject(new Error('Custom templates are unavailable on the web')) +async function listTemplates(): Promise { + if (!(await serverSupportsCustomTemplates())) return [] + return jsonRequest('/templates') } -function writeTemplate(_input: WriteTemplateInput): Promise { - return Promise.reject(new Error('Custom templates are unavailable on the web')) +async function readTemplate(sourcePath: string): Promise { + await requireServerTemplateSupport() + const result = await jsonRequest<{ raw: string }>( + `/templates/read?path=${encodeURIComponent(sourcePath)}` + ) + return result.raw +} + +async function writeTemplate(input: WriteTemplateInput): Promise { + await requireServerTemplateSupport() + return jsonRequest('/templates/write', { + method: 'POST', + body: input as unknown as Record + }) } -function deleteTemplate(_sourcePath: string): Promise { - return Promise.reject(new Error('Custom templates are unavailable on the web')) +async function deleteTemplate(sourcePath: string): Promise { + await requireServerTemplateSupport() + await jsonRequest('/templates/delete', { method: 'POST', body: { sourcePath } }) } // -------------------------------------------------------------------- @@ -1367,12 +1386,11 @@ function clipboardReadText(): string { // -------------------------------------------------------------------- export const httpBridge: ZenBridge = { - // Workflows are the one capability the SERVER decides; derive it from the - // cached /capabilities response instead of mutating the const in place, so - // the UI gate (this) and the request gate (serverSupportsWorkflows) can - // never disagree about the same fact. + // Server-backed features come from the cached /capabilities response instead + // of mutating the const in place, so UI and request gates share one answer. getCapabilities: (): ZenCapabilities => ({ ...WEB_CAPABILITIES, + supportsCustomTemplates: lastServerCapabilities?.supportsCustomTemplates === true, supportsWorkflows: lastServerCapabilities?.supportsWorkflows === true }), getAppInfo: (): ZenAppInfo => WEB_APP_INFO, diff --git a/docs/how-to/connect-desktop-to-remote-server.md b/docs/how-to/connect-desktop-to-remote-server.md index 569feb26..b9bb4f5a 100644 --- a/docs/how-to/connect-desktop-to-remote-server.md +++ b/docs/how-to/connect-desktop-to-remote-server.md @@ -109,6 +109,8 @@ Examples: - the app shows a visual `Remote` indicator - copying an absolute path becomes copying a `Server Path` +- custom templates in `Settings -> Templates` are stored in the remote vault's `.zennotes/templates/` directory and stay live across connected clients +- older servers keep custom-template controls disabled until the server is updated; built-in templates still work - revealing files in Finder or another local file manager may be unavailable or changed, because the file may only exist on the server host Desktop-only shell features still exist because you are still using the desktop app: diff --git a/docs/how-to/self-host-with-docker.md b/docs/how-to/self-host-with-docker.md index 560d4e94..6bcbe4b2 100644 --- a/docs/how-to/self-host-with-docker.md +++ b/docs/how-to/self-host-with-docker.md @@ -248,8 +248,8 @@ or via the orchestrator of your choice. - `ZENNOTES_BROWSE_ROOTS` — directories the server may consider as vault candidates. Anything outside is rejected. - `ZENNOTES_MAX_NOTE_BYTES` / `ZENNOTES_MAX_ASSET_BYTES` — per-request - byte caps for `/api/notes/write` and `/api/assets/upload`. Defaults - 10 MiB and 50 MiB. + byte caps for `/api/notes/write`, `/api/templates/write`, and + `/api/assets/upload`. Defaults 10 MiB and 50 MiB. - `ZENNOTES_VAULT_FILE_MODE` / `ZENNOTES_VAULT_DIR_MODE` — octal mode for new files / directories. Defaults `0600` and `0700`. - `ZENNOTES_BASE_PATH` — mount the API and static bundle under a diff --git a/docs/reference/security-reference.md b/docs/reference/security-reference.md index 6564566d..aef22268 100644 --- a/docs/reference/security-reference.md +++ b/docs/reference/security-reference.md @@ -258,8 +258,8 @@ with another local user. ### Upload and note size limits -- `ZENNOTES_MAX_NOTE_BYTES` — default 10 MiB. `POST /api/notes/write` - rejects bodies larger than this with `413`. +- `ZENNOTES_MAX_NOTE_BYTES` — default 10 MiB. `POST /api/notes/write` and + `POST /api/templates/write` reject content larger than this with `413`. - `ZENNOTES_MAX_ASSET_BYTES` — default 50 MiB. `POST /api/assets/upload` rejects multipart uploads above this with `413`. diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 116b0844..44f57953 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -132,6 +132,8 @@ describe("SettingsModal date note directories", () => { vi.clearAllMocks(); mocks.state.vimMode = false; mocks.state.vimWrappedLineMotions = "logical"; + mocks.state.workspaceMode = "local"; + mocks.state.remoteWorkspaceInfo = null; ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -301,4 +303,52 @@ describe("SettingsModal date note directories", () => { expect(host.textContent).toContain("Keep your vault available everywhere"); expect(host.textContent).toContain("Connect ZenNotes Cloud"); }); + + it("enables custom templates for a capable remote server", async () => { + mocks.state.workspaceMode = "remote"; + mocks.state.remoteWorkspaceInfo = { + mode: "remote", + baseUrl: "https://notes.example.com", + authConfigured: true, + capabilities: { supportsCustomTemplates: true }, + profileId: "remote", + bootError: null, + } as never; + await act(async () => { + root.render(createElement(SettingsModal)); + }); + const templatesButton = [ + ...host.querySelectorAll("button"), + ].find((button) => button.textContent?.trim() === "Templates"); + expect(templatesButton).toBeTruthy(); + await act(async () => templatesButton!.click()); + + expect(host.textContent).toContain("Create a custom template"); + expect(host.textContent).not.toContain("Custom templates need a local vault"); + }); + + it("keeps custom templates disabled for an older remote server", async () => { + mocks.state.workspaceMode = "remote"; + mocks.state.remoteWorkspaceInfo = { + mode: "remote", + baseUrl: "https://notes.example.com", + authConfigured: true, + capabilities: {}, + profileId: "remote", + bootError: null, + } as never; + await act(async () => { + root.render(createElement(SettingsModal)); + }); + const templatesButton = [ + ...host.querySelectorAll("button"), + ].find((button) => button.textContent?.trim() === "Templates"); + expect(templatesButton).toBeTruthy(); + await act(async () => templatesButton!.click()); + + expect(host.textContent).toContain( + "Custom templates need a local vault or a newer ZenNotes server", + ); + expect(host.textContent).not.toContain("Create a custom template"); + }); }); diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index d29553ec..80d08d18 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -590,7 +590,8 @@ export function SettingsModal(): JSX.Element { ); const supportsCustomTemplates = zenBridge.getCapabilities().supportsCustomTemplates && - workspaceMode !== "remote"; + (workspaceMode !== "remote" || + remoteWorkspaceInfo?.capabilities?.supportsCustomTemplates === true); const supportsCustomCodeLanguages = !!zenBridge.getCapabilities().supportsCustomCodeLanguages; const [templateEditor, setTemplateEditor] = useState<{ @@ -4614,8 +4615,8 @@ export function SettingsModal(): JSX.Element { ) : ( - Custom templates require a local vault. Built-in templates still - work here. + Custom templates need a local vault or a newer ZenNotes server. + Built-in templates still work here. )}
diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 66ec5afc..3f2efc39 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1082,7 +1082,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Create a custom template', detail: 'Author a new template as markdown with optional frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and variables like `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, and `{{cursor}}`. It is saved as a `.md` file in `.zennotes/templates/`.' }, { label: 'Edit or reset built-ins', detail: 'Press Edit on a built-in to fork an editable copy that shadows the original everywhere; Reset removes the copy and restores the built-in. Custom templates can be edited or deleted directly.' }, { label: 'Remove or restore built-ins', detail: 'Hide all the shipped templates with “Remove Built-in Templates” (a button here, or the command palette; it asks first), and bring them back with “Restore Built-in Templates”. Your custom templates, and anything already pointing at a built-in by id, keep working.' }, - { label: 'Where templates appear', detail: 'Use a template via the picker (`Space t` / `:template` / “New Note from Template…”), from a folder’s right-click “New from template”, or as the assigned daily/weekly note template. Custom templates require a local vault; built-ins work everywhere.' } + { label: 'Where templates appear', detail: 'Use a template via the picker (`Space t` / `:template` / “New Note from Template…”), from a folder’s right-click “New from template”, or as the assigned daily/weekly note template. Custom templates work in local vaults and on servers that advertise template support; built-ins work everywhere.' } ] }, { diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 0126973e..76f3b691 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -68,6 +68,7 @@ function installZen(overrides: Record = {}): void { listFolders: vi.fn().mockResolvedValue([]), listLocalVaults: vi.fn().mockResolvedValue([]), listAssets: vi.fn().mockResolvedValue([]), + listTemplates: vi.fn().mockResolvedValue([]), hasAssetsDir: vi.fn().mockResolvedValue(false), getRemoteWorkspaceInfo: vi.fn().mockResolvedValue(null), getVaultSettings: vi.fn().mockResolvedValue({}), @@ -178,6 +179,26 @@ describe('tasks cache freshness', () => { }) }) +describe('custom template live updates', () => { + it('reloads custom templates when the vault watcher reports a template change', async () => { + const listTemplates = vi.fn().mockResolvedValue([ + { sourcePath: '.zennotes/templates/standup.md', raw: '---\nname: Standup\n---\n# Notes' } + ]) + installZen({ listTemplates }) + + const { useStore } = await loadStore() + await useStore.getState().applyChange({ + kind: 'change', + path: '.zennotes/templates/standup.md', + folder: 'inbox', + scope: 'templates' + }) + + expect(listTemplates).toHaveBeenCalledTimes(1) + expect(useStore.getState().customTemplates[0]?.name).toBe('Standup') + }) +}) + describe('closed tab history', () => { it('reopens closed tabs in reverse close order', async () => { installZen({ diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 473f6606..d2892be4 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -6203,6 +6203,7 @@ export const useStore = create((set, get) => { await Promise.all([ refreshNotesCoalesced(), get().refreshAssets(), + get().loadCustomTemplates(), window.zen .getVaultSettings() .then((settings) => { @@ -6280,6 +6281,10 @@ export const useStore = create((set, get) => { await get().loadNoteComments(ev.path) return } + if (ev.scope === 'templates') { + await get().loadCustomTemplates() + return + } if (ev.scope === 'database') { // On delete, forget the database instead of re-reading a file that's gone // (which throws "Database not found"); otherwise sync from disk. diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index c82241f4..2e2731d4 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -93,7 +93,7 @@ export interface ZenCapabilities { supportsRemoteWorkspace: boolean supportsCloudSync?: boolean supportsCliInstall: boolean - /** Custom templates require local-filesystem CRUD; false on web/remote. */ + /** Custom template CRUD is available locally or through the connected server. */ supportsCustomTemplates: boolean supportsCustomCodeLanguages?: boolean /** Local desktop support, or a web client paired with a server that owns diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 26242d74..f98cf63c 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -747,6 +747,8 @@ export interface ServerCapabilities { /** Server-side workflow file CRUD plus journalled apply/undo. Absent before * 2.29, where the web client must keep Workflows read-only. */ supportsWorkflows?: boolean + /** Server-side custom-template CRUD. Absent on older servers. */ + supportsCustomTemplates?: boolean } export interface ServerSessionStatus { @@ -827,6 +829,7 @@ export type VaultChangeScope = | 'comments' | 'database' | 'folder' + | 'templates' | 'resync' export interface VaultChangeEvent {