p9/server: rework /new to use YAML frontmatter template pattern

Read /new to get a template with date and identifier pre-filled; write
back (frontmatter + optional body) to create the note on disk. Mirrors
the 9beads new-file pattern. Empty fields (title, signature) render as
bare YAML nulls, not quoted strings.

Adds gopkg.in/yaml.v3 for struct-based template marshaling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-11 23:23:14 +02:00
parent af0da5982b
commit 5e26403f09
3 changed files with 119 additions and 80 deletions

2
go.mod
View File

@ -3,3 +3,5 @@ module denotesrv
go 1.23.0
require 9fans.net/go v0.0.5
require gopkg.in/yaml.v3 v3.0.1 // indirect

3
go.sum
View File

@ -32,3 +32,6 @@ golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -7,7 +7,7 @@ follows:
ctl (write-only) Control file (commands: filter <query>, cd <path>)
event (read-only) Global event bus (listen & react to rename, update, and delete events)
index (read-only) Metadata index (respects active filter)
new (write-only) Create new note (write "'title' tag1,tag2")
new (read-write) Create new note; read for a YAML frontmatter template, then write the filled-in template (with optional body) to create a note
n/ (directory) Notes directory
<identifier>/ (directory) Unique denote file identifier
backlinks (read-only) Notes that link to this note (same format as index)
@ -28,7 +28,7 @@ Notes:
- Messages written to denote/n/<identifier>/ctl may generate events that are broadcast over the global event bus
- Writing to title or keywords triggers an update ('u') event and a rename ('r') event
- Write 'd' to denote/n/<identifier>/ctl to delete a denote file
- Write "'document title' tag1,tag2,(...)" to denote/new to create a new denote file
- Read denote/new for a YAML frontmatter template; fill in title/tags/signature/body and write back to create a note
*/
package fs
@ -43,7 +43,6 @@ import (
"net"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"syscall"
@ -51,6 +50,7 @@ import (
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
"gopkg.in/yaml.v3"
)
// File types in our filesystem
@ -564,84 +564,12 @@ func (s *server) dispatchWrite(f *fid, tag uint16) *plan9.Fcall {
return s.handleCtlCommand(fc)
}
// Handle writes to /new
// Handle writes to /new — frontmatter template format (same pattern as 9beads new).
// Read /new to get the template; fill in title/tags/signature and optional body, then write back.
if f.path == "/new" {
// Parse: 'Title' ==signature tag1,tag2 @subdir
if !strings.HasPrefix(input, "'") {
return errorFcall(fc, "title must be single-quoted")
if err := s.handleNewWrite(f.writeBuf); err != nil {
return errorFcall(fc, err.Error())
}
closeQuote := strings.Index(input[1:], "'")
if closeQuote == -1 {
return errorFcall(fc, "title must be single-quoted (missing closing quote)")
}
title := input[1 : closeQuote+1]
if title == "" {
return errorFcall(fc, "title cannot be empty")
}
remainder := strings.TrimSpace(input[closeQuote+2:])
var signature string
var tags []string
var subdir string
if remainder != "" {
if strings.HasPrefix(remainder, "==") {
spaceIdx := strings.Index(remainder, " ")
if spaceIdx == -1 {
signature = remainder[2:]
remainder = ""
} else {
signature = remainder[2:spaceIdx]
remainder = strings.TrimSpace(remainder[spaceIdx+1:])
}
}
// Extract @subdir if present
if atIdx := strings.LastIndex(remainder, "@"); atIdx != -1 {
subdir = strings.TrimSpace(remainder[atIdx+1:])
remainder = strings.TrimSpace(remainder[:atIdx])
}
if remainder != "" {
tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Nd}]+,)*[\p{Ll}\p{Nd}]+$`)
if !tagPattern.MatchString(remainder) {
return errorFcall(fc, "tags must be comma-delimited lowercase unicode words (no spaces)")
}
tags = strings.Split(remainder, ",")
}
}
identifier := time.Now().Format("20060102T150405")
fm := metadata.NewFrontMatter(title, signature, tags, identifier)
fileName := metadata.BuildFilename(fm, ".md")
dir := filepath.Join(s.denoteDir, subdir)
path := filepath.Join(dir, fileName)
// Ensure directory exists and write initial frontmatter to disk
if err := os.MkdirAll(dir, 0755); err != nil {
return errorFcall(fc, fmt.Sprintf("failed to create directory: %v", err))
}
if err := os.WriteFile(path, frontmatter.Marshal(fm, metadata.FileTypeMdYaml), 0644); err != nil {
return errorFcall(fc, fmt.Sprintf("failed to write file: %v", err))
}
meta := &metadata.Metadata{
Identifier: identifier,
Signature: signature,
Title: title,
Tags: tags,
Path: path,
}
s.mu.Lock()
s.notes = append(s.notes, meta)
s.mu.Unlock()
if s.callbacks.OnNew != nil {
go s.callbacks.OnNew(identifier)
}
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(f.writeBuf))}
}
// Extract note identifier and field name from path
@ -812,7 +740,7 @@ func (s *server) readDir(path string, offset int64, count uint32) []byte {
Type: QTFile,
Path: uint64(qidNew),
},
Mode: 0222,
Mode: 0666,
Name: "new",
Uid: "denote",
Gid: "denote",
@ -1011,6 +939,10 @@ func (s *server) getFileContent(path string) string {
return s.denoteDir
}
if path == "/new" {
return string(s.readNewTemplate())
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) != 3 || parts[0] != "n" {
return ""
@ -1372,3 +1304,105 @@ func splitRespectingQuotes(s string) []string {
return result
}
// newNoteTemplate is the schema for a new note. Field order and yaml tags define
// the template structure; readNewTemplate builds the actual YAML output from this.
type newNoteTemplate struct {
Title string `yaml:"title"`
Date string `yaml:"date"`
Tags []string `yaml:"tags,flow"`
Identifier string `yaml:"identifier"`
Signature string `yaml:"signature"`
}
// readNewTemplate returns a YAML frontmatter template for creating a new note.
// The client reads /new, fills in the fields, then writes back to create the note.
// date and identifier are pre-filled; title and signature are bare-empty (not "").
func (s *server) readNewTemplate() []byte {
now := time.Now()
// bare returns a plain empty scalar — YAML null, reads back as "" for string fields.
bare := func() *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: ""}
}
str := func(v string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v}
}
key := func(k string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Value: k}
}
// Field order matches newNoteTemplate.
doc := &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{
key("title"), bare(),
key("date"), str(now.Format("2006-01-02 Mon 15:04")),
key("tags"), {Kind: yaml.SequenceNode, Tag: "!!seq", Style: yaml.FlowStyle},
key("identifier"), str(now.Format("20060102T150405")),
key("signature"), bare(),
},
}},
}
fmBytes, _ := yaml.Marshal(doc)
var b strings.Builder
b.WriteString("---\n")
b.Write(fmBytes)
b.WriteString("---\n")
return []byte(b.String())
}
// handleNewWrite parses a YAML frontmatter payload (optionally followed by a body)
// written to /new, creates the note file on disk, and registers it in memory.
func (s *server) handleNewWrite(data []byte) error {
parsed, _, err := frontmatter.Unmarshal(data, ".md")
if err != nil {
return fmt.Errorf("new: parse frontmatter: %w", err)
}
if parsed == nil || parsed.Title == "" {
return fmt.Errorf("new: title is required")
}
identifier := time.Now().Format("20060102T150405")
fm := metadata.NewFrontMatter(parsed.Title, parsed.Signature, parsed.Tags, identifier)
fileName := metadata.BuildFilename(fm, ".md")
path := filepath.Join(s.denoteDir, fileName)
// Build full file content: regenerated frontmatter + body from the write payload.
header := frontmatter.Marshal(fm, metadata.FileTypeMdYaml)
body := ""
if idx := strings.Index(string(data), "\n---\n"); idx >= 0 {
body = strings.TrimRight(string(data)[idx+5:], "\n")
}
fileContent := string(header)
if body != "" {
fileContent += body + "\n"
}
if err := os.MkdirAll(s.denoteDir, 0700); err != nil {
return fmt.Errorf("new: mkdir: %w", err)
}
if err := os.WriteFile(path, []byte(fileContent), 0600); err != nil {
return fmt.Errorf("new: write: %w", err)
}
meta := &metadata.Metadata{
Identifier: identifier,
Signature: parsed.Signature,
Title: parsed.Title,
Tags: parsed.Tags,
Path: path,
}
s.mu.Lock()
s.notes = append(s.notes, meta)
s.mu.Unlock()
if s.callbacks.OnNew != nil {
go s.callbacks.OnNew(identifier)
}
return nil
}