Skip to content

Commit e2bb6aa

Browse files
committed
Feat(templates): support custom templates in remote vaults
1 parent d19665f commit e2bb6aa

23 files changed

Lines changed: 666 additions & 31 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3250,34 +3250,45 @@ function registerIpc(): void {
32503250
return await deleteWorkflowRuns(v.root, workflowId);
32513251
});
32523252

3253-
// Custom templates live on the local filesystem only; remote vaults fall
3254-
// back to built-in templates (renderer constants), so list returns empty and
3255-
// mutations are rejected.
3253+
const requireRemoteTemplates = () => {
3254+
const client = requireRemoteWorkspaceClient();
3255+
if (!remoteServerCapabilities?.supportsCustomTemplates) {
3256+
throw new Error(
3257+
"Custom templates need a newer ZenNotes server. Update the server and reconnect.",
3258+
);
3259+
}
3260+
return client;
3261+
};
3262+
32563263
handle(IPC.VAULT_LIST_TEMPLATES, async () => {
3257-
if (isRemoteWorkspaceActive()) return [];
3264+
if (isRemoteWorkspaceActive()) {
3265+
return remoteServerCapabilities?.supportsCustomTemplates
3266+
? await requireRemoteWorkspaceClient().listTemplates()
3267+
: [];
3268+
}
32583269
const v = requireVault();
32593270
return await listCustomTemplates(v.root);
32603271
});
32613272

32623273
handle(IPC.VAULT_READ_TEMPLATE, async (_e, sourcePath: string) => {
32633274
if (isRemoteWorkspaceActive()) {
3264-
throw new Error("Custom templates are unavailable on remote vaults");
3275+
return await requireRemoteTemplates().readTemplate(sourcePath);
32653276
}
32663277
const v = requireVault();
32673278
return await readCustomTemplate(v.root, sourcePath);
32683279
});
32693280

32703281
handle(IPC.VAULT_WRITE_TEMPLATE, async (_e, input: WriteTemplateInput) => {
32713282
if (isRemoteWorkspaceActive()) {
3272-
throw new Error("Custom templates are unavailable on remote vaults");
3283+
return await requireRemoteTemplates().writeTemplate(input);
32733284
}
32743285
const v = requireVault();
32753286
return await writeCustomTemplate(v.root, input);
32763287
});
32773288

32783289
handle(IPC.VAULT_DELETE_TEMPLATE, async (_e, sourcePath: string) => {
32793290
if (isRemoteWorkspaceActive()) {
3280-
throw new Error("Custom templates are unavailable on remote vaults");
3291+
return await requireRemoteTemplates().deleteTemplate(sourcePath);
32813292
}
32823293
const v = requireVault();
32833294
return await deleteCustomTemplate(v.root, sourcePath);

apps/desktop/src/main/remote/server-client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import type {
2020
VaultTextSearchToolPaths
2121
} from '@shared/ipc'
2222
import type { VaultTask } from '@shared/tasks'
23+
import type {
24+
CustomTemplateFile,
25+
WriteTemplateInput
26+
} from '@zennotes/bridge-contract/templates'
2327
import WebSocket from 'ws'
2428
import {
2529
connectionErrorMessage,
@@ -201,6 +205,28 @@ export class RemoteServerClient {
201205
})
202206
}
203207

208+
async listTemplates(): Promise<CustomTemplateFile[]> {
209+
return this.jsonRequest<CustomTemplateFile[]>('/api/templates')
210+
}
211+
212+
async readTemplate(sourcePath: string): Promise<string> {
213+
const result = await this.jsonRequest<{ raw: string }>(
214+
`/api/templates/read?path=${encodeURIComponent(sourcePath)}`
215+
)
216+
return result.raw
217+
}
218+
219+
async writeTemplate(input: WriteTemplateInput): Promise<CustomTemplateFile> {
220+
return this.jsonRequest<CustomTemplateFile>('/api/templates/write', {
221+
method: 'POST',
222+
body: input as unknown as Record<string, unknown>
223+
})
224+
}
225+
226+
async deleteTemplate(sourcePath: string): Promise<void> {
227+
await this.jsonRequest('/api/templates/delete', { method: 'POST', body: { sourcePath } })
228+
}
229+
204230
async readNote(relPath: string): Promise<NoteContent> {
205231
return this.jsonRequest<NoteContent>(`/api/notes/read?path=${encodeURIComponent(relPath)}`)
206232
}

apps/desktop/src/main/templates.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ function safeSlug(slug: string): string {
2727
.toLowerCase()
2828
.replace(/[^a-z0-9-]+/g, '-')
2929
.replace(/^-+|-+$/g, '')
30+
.slice(0, 64)
31+
.replace(/-+$/g, '')
3032
return cleaned || 'template'
3133
}
3234

apps/desktop/src/main/watcher.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,26 @@ describe('VaultWatcher atomic saves', () => {
124124
20_000
125125
)
126126
})
127+
128+
describe('VaultWatcher custom templates', () => {
129+
it(
130+
'reports template changes from the hidden vault directory',
131+
async () => {
132+
const root = await makeVault()
133+
const events: VaultChangeEvent[] = []
134+
const watcher = new VaultWatcher()
135+
watchers.push(watcher)
136+
watcher.start(root, (event) => events.push(event))
137+
await sleep(400)
138+
139+
const template = path.join(root, '.zennotes', 'templates', 'standup.md')
140+
await mkdir(path.dirname(template), { recursive: true })
141+
await writeFile(template, '# Standup\n')
142+
143+
const event = await waitForEvent(events)
144+
expect(event?.path).toBe('.zennotes/templates/standup.md')
145+
expect(event?.scope).toBe('templates')
146+
},
147+
20_000
148+
)
149+
})

apps/desktop/src/main/watcher.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const INTERNAL_VAULT_DIR = '.zennotes'
99
const VAULT_SETTINGS_RELATIVE_PATH = `${INTERNAL_VAULT_DIR}/vault.json`
1010
const NOTE_COMMENTS_PREFIX = `${INTERNAL_VAULT_DIR}/comments/`
1111
const NOTE_COMMENTS_SUFFIX = '.comments.json'
12+
const TEMPLATES_PREFIX = `${INTERNAL_VAULT_DIR}/templates/`
1213

1314
function toPosix(p: string): string {
1415
return p.split(path.sep).join('/')
@@ -129,6 +130,11 @@ export class VaultWatcher {
129130
)
130131
return
131132
}
133+
const rel = relativeVaultPath(this.root, absPath)
134+
if (rel.startsWith(TEMPLATES_PREFIX) && rel.toLowerCase().endsWith('.md')) {
135+
onEvent({ kind, path: rel, folder: 'inbox', scope: 'templates' })
136+
return
137+
}
132138
// Any database file — `<Name>.base/data.csv` or `schema.json` (or a legacy
133139
// loose `.csv`/sidecar) — normalizes to the canonical `data.csv` path so
134140
// the renderer re-hydrates the right database. (Record-page `.md` notes in

apps/server/internal/httpserver/server.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,11 @@ func (s *Server) registerProtectedRoutes(r chi.Router) {
238238
r.Get("/workflows/runs", s.listWorkflowRuns)
239239
r.Post("/workflows/runs/delete", s.deleteWorkflowRuns)
240240

241+
r.Get("/templates", s.listTemplates)
242+
r.Get("/templates/read", s.readTemplate)
243+
r.Post("/templates/write", s.writeTemplate)
244+
r.Post("/templates/delete", s.deleteTemplate)
245+
241246
r.Get("/watch", s.watchWS)
242247
}
243248

@@ -301,6 +306,10 @@ func writeError(w http.ResponseWriter, err error) {
301306
http.Error(w, err.Error(), http.StatusBadRequest)
302307
return
303308
}
309+
if errors.Is(err, vault.ErrInvalidTemplate) {
310+
http.Error(w, err.Error(), http.StatusBadRequest)
311+
return
312+
}
304313
if errors.Is(err, vault.ErrWorkflowConflict) {
305314
http.Error(w, err.Error(), http.StatusConflict)
306315
return
@@ -415,6 +424,9 @@ func (s *Server) capabilities(w http.ResponseWriter, _ *http.Request) {
415424
// prepared-run endpoint applies them under the same vault lock as note
416425
// writes. Its presence lets bundled web clients enable authoring and Run.
417426
"supportsWorkflows": true,
427+
// Custom template files use the same mounted-vault ownership model as
428+
// workflows; clients can safely enable authoring when this is present.
429+
"supportsCustomTemplates": true,
418430
// Says out loud that a missing file answers 404 rather than 500.
419431
// Databases are composed from file reads where "absent" and "failed"
420432
// mean opposite things (see remote-absence.ts), and a server that
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package httpserver
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/ZenNotes/zennotes/apps/server/internal/vault"
7+
)
8+
9+
func (s *Server) listTemplates(w http.ResponseWriter, _ *http.Request) {
10+
files, err := s.currentVault().ListTemplates()
11+
if err != nil {
12+
writeError(w, err)
13+
return
14+
}
15+
writeJSON(w, http.StatusOK, files)
16+
}
17+
18+
func (s *Server) readTemplate(w http.ResponseWriter, r *http.Request) {
19+
raw, err := s.currentVault().ReadTemplate(r.URL.Query().Get("path"))
20+
if err != nil {
21+
writeError(w, err)
22+
return
23+
}
24+
writeJSON(w, http.StatusOK, map[string]string{"raw": raw})
25+
}
26+
27+
func (s *Server) writeTemplate(w http.ResponseWriter, r *http.Request) {
28+
cfg := s.currentConfig()
29+
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes)
30+
var input vault.WriteTemplateInput
31+
if err := readJSON(r, &input); err != nil {
32+
http.Error(w, err.Error(), http.StatusBadRequest)
33+
return
34+
}
35+
if cfg.MaxNoteBytes > 0 && int64(len(input.Raw)) > cfg.MaxNoteBytes {
36+
http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge)
37+
return
38+
}
39+
file, err := s.currentVault().WriteTemplate(input)
40+
if err != nil {
41+
writeError(w, err)
42+
return
43+
}
44+
writeJSON(w, http.StatusOK, file)
45+
}
46+
47+
func (s *Server) deleteTemplate(w http.ResponseWriter, r *http.Request) {
48+
r.Body = http.MaxBytesReader(w, r.Body, jsonEnvelopeBytes)
49+
var request struct {
50+
SourcePath string `json:"sourcePath"`
51+
}
52+
if err := readJSON(r, &request); err != nil {
53+
http.Error(w, err.Error(), http.StatusBadRequest)
54+
return
55+
}
56+
if err := s.currentVault().DeleteTemplate(request.SourcePath); err != nil {
57+
writeError(w, err)
58+
return
59+
}
60+
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
61+
}

0 commit comments

Comments
 (0)