Skip to content
Merged
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
96 changes: 96 additions & 0 deletions internal/vault/display_name.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package vault

import (
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
)

// A vault's display name (#692): what the clients call the vault, kept in
// `.zennotes/vault.json` as `displayName`, distinct from the folder's name on
// disk. Byte-for-byte mirror of packages/shared-domain/src/vault-display-name.ts:
// change both together. A name that survives one runtime's round-trip must
// survive the other's, or a desktop-written name is lost on the web client's
// next settings save (the #446/#379 round-trip rule).
//
// Absent means the folder name, so a vault that never set one behaves exactly
// as before, and clearing the field is the same as never having set it.

// maxVaultDisplayNameLength is longer than any name that fits a sidebar
// header; a limit rather than a layout rule, so a pasted paragraph cannot
// become the vault's name. Counted in UTF-16 code units, as the TypeScript
// side's `String.length` does, so the two runtimes cut at the same place.
const maxVaultDisplayNameLength = 64

// normalizeVaultDisplayName is the name as it is stored and shown, in this
// order: C0 and C1 control characters other than the whitespace ones dropped,
// runs of whitespace (tabs, newlines and the Unicode separators included)
// collapsed to one space, trimmed, cut at the limit without splitting a
// surrogate pair. Empty when nothing usable is left, so vault.json is written
// without the key (`omitempty`) and every reader's folder-name fallback holds.
func normalizeVaultDisplayName(raw string) string {
if !utf8.ValidString(raw) {
raw = strings.ToValidUTF8(raw, "")
}
var b strings.Builder
pendingSpace := false
for _, r := range raw {
switch {
case isJSWhitespace(r):
pendingSpace = true
case r < 0x20 || (r >= 0x7f && r <= 0x9f):
// Dropped. Doing this inside the whitespace pass is the same as
// dropping first: a control between two spaces leaves one space.
default:
if pendingSpace && b.Len() > 0 {
b.WriteByte(' ')
}
pendingSpace = false
b.WriteRune(r)
}
}
cleaned := b.String()
if cleaned == "" {
return ""
}
return truncateUTF16(cleaned, maxVaultDisplayNameLength)
}

// isJSWhitespace matches JavaScript's `\s`: the ASCII whitespace controls,
// the Unicode space separators, the line and paragraph separators, and the
// BOM, which Go's unicode.IsSpace does not count.
func isJSWhitespace(r rune) bool {
switch r {
case '\t', '\n', '\v', '\f', '\r', ' ', 0x2028, 0x2029, 0xfeff:
return true
}
return unicode.Is(unicode.Zs, r)
}

// truncateUTF16 cuts s after at most limit UTF-16 code units, never inside a
// surrogate pair, and trims the space a cut can leave at the end. Only ' '
// can be there: the whitespace pass left no other.
func truncateUTF16(s string, limit int) string {
units := 0
for i, r := range s {
width := 1
if r > 0xffff {
width = 2
}
if units+width > limit {
return strings.TrimRight(s[:i], " ")
}
units += width
}
return s
}

// resolveVaultName is the name a vault goes by: its display name when it has
// one, else the folder's own.
func resolveVaultName(settings VaultSettings, root string) string {
if name := normalizeVaultDisplayName(settings.DisplayName); name != "" {
return name
}
return filepath.Base(root)
}
130 changes: 130 additions & 0 deletions internal/vault/display_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package vault

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

// These cases mirror packages/shared-domain/src/vault-display-name.test.ts one
// for one. When either side gains a rule, add it here too: the two
// implementations only stay compatible if they are tested on the same inputs.

func TestNormalizeVaultDisplayName(t *testing.T) {
cases := map[string]string{
"Acme API docs": "Acme API docs",
"Été 2026 · notes": "Été 2026 · notes",
" Acme API\tdocs \n": "Acme API docs",
"Acme\x00 docs\u2028v2\x07": "Acme docs v2",
"A \x07 B": "A B",
"": "",
" ": "",
"\x07": "",
"\ufeff": "",
strings.Repeat("x", 64): strings.Repeat("x", 64),
strings.Repeat("x", 63) + "😀tail": strings.Repeat("x", 63),
strings.Repeat("x", 62) + "😀tail": strings.Repeat("x", 62) + "😀",
}
for input, want := range cases {
if got := normalizeVaultDisplayName(input); got != want {
t.Errorf("normalizeVaultDisplayName(%q) = %q, want %q", input, got, want)
}
}

long := strings.Repeat("word ", 20) + "end"
got := normalizeVaultDisplayName(long)
if units := utf16Len(got); units > maxVaultDisplayNameLength {
t.Errorf("long name kept %d UTF-16 units, want at most %d", units, maxVaultDisplayNameLength)
}
if got != strings.TrimRight(got, " ") {
t.Errorf("cut left a trailing space: %q", got)
}
}

func utf16Len(s string) int {
n := 0
for _, r := range s {
if r > 0xffff {
n += 2
} else {
n++
}
}
return n
}

func TestResolveVaultName(t *testing.T) {
root := filepath.Join("repos", "acme", "docs")
if got := resolveVaultName(VaultSettings{DisplayName: "Acme API docs"}, root); got != "Acme API docs" {
t.Errorf("display name = %q", got)
}
if got := resolveVaultName(VaultSettings{DisplayName: " "}, root); got != "docs" {
t.Errorf("blank display name = %q, want the folder", got)
}
if got := resolveVaultName(VaultSettings{}, root); got != "docs" {
t.Errorf("no display name = %q, want the folder", got)
}
}

// A desktop-written display name must survive a web-side settings save, and
// the vault must answer with it: the #446/#379 round-trip rule for #692.
func TestVaultDisplayNameRoundTripAndInfo(t *testing.T) {
root := t.TempDir()
v, err := New(root, Options{})
if err != nil {
t.Fatal(err)
}
if got := v.Info().Name; got != filepath.Base(root) {
t.Fatalf("Info before a name = %q, want the folder %q", got, filepath.Base(root))
}

returned, err := v.SetSettings(VaultSettings{PrimaryNotesLocation: PrimaryNotesInbox, DisplayName: " Acme API docs "})
if err != nil {
t.Fatal(err)
}
if returned.DisplayName != "Acme API docs" {
t.Errorf("SetSettings returned %q", returned.DisplayName)
}
saved, err := v.GetSettings()
if err != nil {
t.Fatal(err)
}
if saved.DisplayName != "Acme API docs" {
t.Errorf("GetSettings = %q", saved.DisplayName)
}
if got := v.Info().Name; got != "Acme API docs" {
t.Errorf("Info = %q, want the display name", got)
}

// The web client's usual save: everything it read, written back.
if _, err := v.SetSettings(saved); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(root, ".zennotes", "vault.json"))
if err != nil {
t.Fatal(err)
}
var onDisk map[string]any
if err := json.Unmarshal(raw, &onDisk); err != nil {
t.Fatal(err)
}
if onDisk["displayName"] != "Acme API docs" {
t.Errorf("vault.json displayName after a round-trip = %v", onDisk["displayName"])
}

// Cleared: the key goes away rather than staying empty, and Info falls
// back to the folder.
saved.DisplayName = " "
if _, err := v.SetSettings(saved); err != nil {
t.Fatal(err)
}
raw, _ = os.ReadFile(filepath.Join(root, ".zennotes", "vault.json"))
if strings.Contains(string(raw), "displayName") {
t.Errorf("cleared name still in vault.json: %s", raw)
}
if got := v.Info().Name; got != filepath.Base(root) {
t.Errorf("Info after clearing = %q, want the folder", got)
}
}
6 changes: 6 additions & 0 deletions internal/vault/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ type FileLocationSetting struct {
}

type VaultSettings struct {
// DisplayName is what the clients call the vault (#692): the sidebar
// header, the vault switcher, the title bar. Mirrors shared/ipc.ts
// VaultSettings.displayName; absent means the folder's own name. A
// first-class field for the same round-trip reason as Tasks below, and
// what Info() answers with when it is set.
DisplayName string `json:"displayName,omitempty"`
PrimaryNotesLocation PrimaryNotesLocation `json:"primaryNotesLocation"`
DailyNotes DailyNotesSettings `json:"dailyNotes"`
WeeklyNotes WeeklyNotesSettings `json:"weeklyNotes"`
Expand Down
11 changes: 10 additions & 1 deletion internal/vault/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,15 @@ func (v *Vault) Root() string {
return v.root
}

// Info describes the vault as the clients name it: its display name from
// vault.json when it has one (#692), else the folder name. Reads through the
// settings cache, so it costs one stat once the file has been parsed.
func (v *Vault) Info() VaultInfo {
return VaultInfo{Root: v.root, Name: filepath.Base(v.root)}
name := filepath.Base(v.root)
if settings, err := v.GetSettings(); err == nil {
name = resolveVaultName(settings, v.root)
}
return VaultInfo{Root: v.root, Name: name}
}

func cloneSettings(settings VaultSettings) VaultSettings {
Expand Down Expand Up @@ -375,6 +382,7 @@ func cloneSettings(settings VaultSettings) VaultSettings {
monthlyLegacyPatterns := make([]DateNotePatternSettings, len(settings.MonthlyNotes.LegacyPatterns))
copy(monthlyLegacyPatterns, settings.MonthlyNotes.LegacyPatterns)
return VaultSettings{
DisplayName: settings.DisplayName,
PrimaryNotesLocation: settings.PrimaryNotesLocation,
DailyNotes: DailyNotesSettings{
Enabled: settings.DailyNotes.Enabled,
Expand Down Expand Up @@ -573,6 +581,7 @@ func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLoc
folderColors[key] = value
}
return VaultSettings{
DisplayName: normalizeVaultDisplayName(value.DisplayName),
PrimaryNotesLocation: normalizePrimaryNotesLocation(func() PrimaryNotesLocation {
if value.PrimaryNotesLocation == "" {
return fallbackPrimary
Expand Down
2 changes: 1 addition & 1 deletion release.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "2.55.0",
"version": "2.56.0",
"vendorHash": "sha256-ZdOHC2JldvnKSDUFnBUJrKD4F1IWfvYJBksgeDnU9cw="
}
Loading
Loading