Add NRC (Nostr Relay Connect) protocol and web UI (v0.48.9)
Some checks failed
Go / build-and-release (push) Has been cancelled
Some checks failed
Go / build-and-release (push) Has been cancelled
- Implement NIP-NRC protocol for remote relay access through public relay tunnel - Add NRC bridge service with NIP-44 encrypted message tunneling - Add NRC client library for applications - Add session management with subscription tracking and expiry - Add URI parsing for nostr+relayconnect:// scheme with secret and CAT auth - Add NRC API endpoints for connection management (create/list/delete/get-uri) - Add RelayConnectView.svelte component for managing NRC connections in web UI - Add NRC database storage for connection secrets and labels - Add NRC CLI commands (generate, list, revoke) - Add support for Cashu Access Tokens (CAT) in NRC URIs - Add ScopeNRC constant for Cashu token scope - Add wasm build infrastructure and stub files Files modified: - app/config/config.go: NRC configuration options - app/handle-nrc.go: New API handlers for NRC connections - app/main.go: NRC bridge startup integration - app/server.go: Register NRC API routes - app/web/src/App.svelte: Add Relay Connect tab - app/web/src/RelayConnectView.svelte: New NRC management component - app/web/src/api.js: NRC API client functions - main.go: NRC CLI command handlers - pkg/bunker/acl_adapter.go: Add NRC scope mapping - pkg/cashu/token/token.go: Add ScopeNRC constant - pkg/database/nrc.go: NRC connection storage - pkg/protocol/nrc/: New NRC protocol implementation - docs/NIP-NRC.md: NIP specification document 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
49
pkg/database/bufpool/pool_wasm.go
Normal file
49
pkg/database/bufpool/pool_wasm.go
Normal file
@@ -0,0 +1,49 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Package bufpool provides buffer pools for reducing GC pressure in hot paths.
|
||||
// This is the WASM version which uses simple allocations since sync.Pool
|
||||
// behavior differs in WASM environments.
|
||||
package bufpool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
)
|
||||
|
||||
const (
|
||||
// SmallBufferSize for index keys (8-64 bytes typical)
|
||||
SmallBufferSize = 64
|
||||
|
||||
// MediumBufferSize for event encoding (300-1000 bytes typical)
|
||||
MediumBufferSize = 1024
|
||||
)
|
||||
|
||||
// GetSmall returns a small buffer (64 bytes).
|
||||
// In WASM, we simply allocate new buffers as sync.Pool is less effective.
|
||||
func GetSmall() *bytes.Buffer {
|
||||
return bytes.NewBuffer(make([]byte, 0, SmallBufferSize))
|
||||
}
|
||||
|
||||
// PutSmall is a no-op in WASM; the buffer will be garbage collected.
|
||||
func PutSmall(buf *bytes.Buffer) {
|
||||
// No-op in WASM
|
||||
}
|
||||
|
||||
// GetMedium returns a medium buffer (1KB).
|
||||
func GetMedium() *bytes.Buffer {
|
||||
return bytes.NewBuffer(make([]byte, 0, MediumBufferSize))
|
||||
}
|
||||
|
||||
// PutMedium is a no-op in WASM; the buffer will be garbage collected.
|
||||
func PutMedium(buf *bytes.Buffer) {
|
||||
// No-op in WASM
|
||||
}
|
||||
|
||||
// CopyBytes copies the buffer contents to a new slice.
|
||||
func CopyBytes(buf *bytes.Buffer) []byte {
|
||||
if buf == nil || buf.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]byte, buf.Len())
|
||||
copy(result, buf.Bytes())
|
||||
return result
|
||||
}
|
||||
206
pkg/database/nrc.go
Normal file
206
pkg/database/nrc.go
Normal file
@@ -0,0 +1,206 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
|
||||
"git.mleku.dev/mleku/nostr/crypto/keys"
|
||||
"git.mleku.dev/mleku/nostr/encoders/hex"
|
||||
)
|
||||
|
||||
// Key prefixes for NRC data
|
||||
const (
|
||||
nrcConnectionPrefix = "nrc:conn:" // NRC connections by ID
|
||||
)
|
||||
|
||||
// NRCConnection stores an NRC connection configuration in the database.
|
||||
type NRCConnection struct {
|
||||
ID string `json:"id"` // Unique identifier (hex of first 8 bytes of secret)
|
||||
Label string `json:"label"` // Human-readable label (e.g., "Phone", "Laptop")
|
||||
Secret []byte `json:"secret"` // 32-byte secret for client authentication
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp
|
||||
LastUsed int64 `json:"last_used"` // Unix timestamp of last connection (0 if never)
|
||||
UseCashu bool `json:"use_cashu"` // Whether to include CAT token in URI
|
||||
}
|
||||
|
||||
// GetNRCConnection retrieves an NRC connection by ID.
|
||||
func (d *D) GetNRCConnection(id string) (conn *NRCConnection, err error) {
|
||||
key := []byte(nrcConnectionPrefix + id)
|
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get(key)
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return item.Value(func(val []byte) error {
|
||||
conn = &NRCConnection{}
|
||||
return json.Unmarshal(val, conn)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// SaveNRCConnection stores an NRC connection in the database.
|
||||
func (d *D) SaveNRCConnection(conn *NRCConnection) error {
|
||||
data, err := json.Marshal(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal connection: %w", err)
|
||||
}
|
||||
|
||||
key := []byte(nrcConnectionPrefix + conn.ID)
|
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error {
|
||||
return txn.Set(key, data)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteNRCConnection removes an NRC connection from the database.
|
||||
func (d *D) DeleteNRCConnection(id string) error {
|
||||
key := []byte(nrcConnectionPrefix + id)
|
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error {
|
||||
if err := txn.Delete(key); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllNRCConnections returns all NRC connections.
|
||||
func (d *D) GetAllNRCConnections() (conns []*NRCConnection, err error) {
|
||||
prefix := []byte(nrcConnectionPrefix)
|
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = prefix
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
|
||||
item := it.Item()
|
||||
err := item.Value(func(val []byte) error {
|
||||
conn := &NRCConnection{}
|
||||
if err := json.Unmarshal(val, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
conns = append(conns, conn)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// CreateNRCConnection generates a new NRC connection with a random secret.
|
||||
func (d *D) CreateNRCConnection(label string, useCashu bool) (*NRCConnection, error) {
|
||||
// Generate random 32-byte secret
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random secret: %w", err)
|
||||
}
|
||||
|
||||
// Use first 8 bytes of secret as ID (hex encoded = 16 chars)
|
||||
id := string(hex.Enc(secret[:8]))
|
||||
|
||||
conn := &NRCConnection{
|
||||
ID: id,
|
||||
Label: label,
|
||||
Secret: secret,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
LastUsed: 0,
|
||||
UseCashu: useCashu,
|
||||
}
|
||||
|
||||
if err := d.SaveNRCConnection(conn); chk.E(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.I.F("created NRC connection: id=%s label=%s cashu=%v", id, label, useCashu)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// GetNRCConnectionURI generates the full connection URI for a connection.
|
||||
// relayPubkey is the relay's public key (32 bytes).
|
||||
// rendezvousURL is the public relay URL.
|
||||
// mintURL is the CAT mint URL (required if useCashu is true).
|
||||
func (d *D) GetNRCConnectionURI(conn *NRCConnection, relayPubkey []byte, rendezvousURL, mintURL string) (string, error) {
|
||||
if len(relayPubkey) != 32 {
|
||||
return "", fmt.Errorf("invalid relay pubkey length: %d", len(relayPubkey))
|
||||
}
|
||||
if rendezvousURL == "" {
|
||||
return "", fmt.Errorf("rendezvous URL is required")
|
||||
}
|
||||
|
||||
relayPubkeyHex := hex.Enc(relayPubkey)
|
||||
secretHex := hex.Enc(conn.Secret)
|
||||
|
||||
var uri string
|
||||
if conn.UseCashu {
|
||||
if mintURL == "" {
|
||||
return "", fmt.Errorf("mint URL is required for CAT authentication")
|
||||
}
|
||||
// CAT-based URI includes both secret (for non-CAT relays) and CAT auth
|
||||
uri = fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s&auth=cat&mint=%s",
|
||||
relayPubkeyHex, rendezvousURL, secretHex, mintURL)
|
||||
} else {
|
||||
// Secret-only URI
|
||||
uri = fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s",
|
||||
relayPubkeyHex, rendezvousURL, secretHex)
|
||||
}
|
||||
|
||||
if conn.Label != "" {
|
||||
uri += fmt.Sprintf("&name=%s", conn.Label)
|
||||
}
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
// GetNRCAuthorizedSecrets returns a map of derived pubkeys to labels for all connections.
|
||||
// This is used by the NRC bridge to authorize incoming connections.
|
||||
func (d *D) GetNRCAuthorizedSecrets() (map[string]string, error) {
|
||||
conns, err := d.GetAllNRCConnections()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]string)
|
||||
for _, conn := range conns {
|
||||
// Derive pubkey from secret
|
||||
pubkey, err := keys.SecretBytesToPubKeyBytes(conn.Secret)
|
||||
if err != nil {
|
||||
log.W.F("failed to derive pubkey for NRC connection %s: %v", conn.ID, err)
|
||||
continue
|
||||
}
|
||||
pubkeyHex := string(hex.Enc(pubkey))
|
||||
result[pubkeyHex] = conn.Label
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateNRCConnectionLastUsed updates the last used timestamp for a connection.
|
||||
func (d *D) UpdateNRCConnectionLastUsed(id string) error {
|
||||
conn, err := d.GetNRCConnection(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn.LastUsed = time.Now().Unix()
|
||||
return d.SaveNRCConnection(conn)
|
||||
}
|
||||
202
pkg/database/tokenize_wasm.go
Normal file
202
pkg/database/tokenize_wasm.go
Normal file
@@ -0,0 +1,202 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// TokenHashes extracts unique word hashes (8-byte truncated sha256) from content.
|
||||
// Rules:
|
||||
// - Unicode-aware: words are sequences of letters or numbers.
|
||||
// - Lowercased using unicode case mapping.
|
||||
// - Ignore URLs (starting with http://, https://, www., or containing "://").
|
||||
// - Ignore nostr: URIs and #[n] mentions.
|
||||
// - Ignore words shorter than 2 runes.
|
||||
// - Exclude 64-character hexadecimal strings (likely IDs/pubkeys).
|
||||
func TokenHashes(content []byte) [][]byte {
|
||||
s := string(content)
|
||||
var out [][]byte
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
r, size := rune(s[i]), 1
|
||||
if r >= 0x80 {
|
||||
r, size = utf8DecodeRuneInString(s[i:])
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
if unicode.IsSpace(r) {
|
||||
i += size
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip URLs and schemes
|
||||
if hasPrefixFold(s[i:], "http://") || hasPrefixFold(s[i:], "https://") || hasPrefixFold(s[i:], "nostr:") || hasPrefixFold(s[i:], "www.") {
|
||||
i = skipUntilSpace(s, i)
|
||||
continue
|
||||
}
|
||||
// If token contains "://" ahead, treat as URL and skip to space
|
||||
if j := strings.Index(s[i:], "://"); j == 0 || (j > 0 && isWordStart(r)) {
|
||||
// Only if it's at start of token
|
||||
before := s[i : i+j]
|
||||
if len(before) == 0 || allAlphaNum(before) {
|
||||
i = skipUntilSpace(s, i)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Skip #[n] mentions
|
||||
if r == '#' && i+size < len(s) && s[i+size] == '[' {
|
||||
end := strings.IndexByte(s[i:], ']')
|
||||
if end >= 0 {
|
||||
i += end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Collect a word
|
||||
start := i
|
||||
var runes []rune
|
||||
for i < len(s) {
|
||||
r2, size2 := rune(s[i]), 1
|
||||
if r2 >= 0x80 {
|
||||
r2, size2 = utf8DecodeRuneInString(s[i:])
|
||||
}
|
||||
if unicode.IsLetter(r2) || unicode.IsNumber(r2) {
|
||||
// Normalize decorative unicode (small caps, fraktur) to ASCII
|
||||
// before lowercasing for consistent indexing
|
||||
runes = append(runes, unicode.ToLower(normalizeRune(r2)))
|
||||
i += size2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
// If we didn't consume any rune for a word, advance by one rune to avoid stalling
|
||||
if i == start {
|
||||
_, size2 := utf8DecodeRuneInString(s[i:])
|
||||
i += size2
|
||||
continue
|
||||
}
|
||||
if len(runes) >= 2 {
|
||||
w := string(runes)
|
||||
// Exclude 64-char hex strings
|
||||
if isHex64(w) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[w]; !ok {
|
||||
seen[w] = struct{}{}
|
||||
h := sha256.Sum256([]byte(w))
|
||||
out = append(out, h[:8])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasPrefixFold(s, prefix string) bool {
|
||||
if len(s) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(prefix); i++ {
|
||||
c := s[i]
|
||||
p := prefix[i]
|
||||
if c == p {
|
||||
continue
|
||||
}
|
||||
// ASCII case-insensitive
|
||||
if 'A' <= c && c <= 'Z' {
|
||||
c = c - 'A' + 'a'
|
||||
}
|
||||
if 'A' <= p && p <= 'Z' {
|
||||
p = p - 'A' + 'a'
|
||||
}
|
||||
if c != p {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func skipUntilSpace(s string, i int) int {
|
||||
for i < len(s) {
|
||||
r, size := rune(s[i]), 1
|
||||
if r >= 0x80 {
|
||||
r, size = utf8DecodeRuneInString(s[i:])
|
||||
}
|
||||
if unicode.IsSpace(r) {
|
||||
return i
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func allAlphaNum(s string) bool {
|
||||
for _, r := range s {
|
||||
if !(unicode.IsLetter(r) || unicode.IsNumber(r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isWordStart(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) }
|
||||
|
||||
// utf8DecodeRuneInString decodes the first UTF-8 rune from s.
|
||||
// Returns the rune and the number of bytes consumed.
|
||||
func utf8DecodeRuneInString(s string) (r rune, size int) {
|
||||
if len(s) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// ASCII fast path
|
||||
b := s[0]
|
||||
if b < 0x80 {
|
||||
return rune(b), 1
|
||||
}
|
||||
// Multi-byte: determine expected length from first byte
|
||||
var expectedLen int
|
||||
switch {
|
||||
case b&0xE0 == 0xC0: // 110xxxxx - 2 bytes
|
||||
expectedLen = 2
|
||||
case b&0xF0 == 0xE0: // 1110xxxx - 3 bytes
|
||||
expectedLen = 3
|
||||
case b&0xF8 == 0xF0: // 11110xxx - 4 bytes
|
||||
expectedLen = 4
|
||||
default:
|
||||
// Invalid UTF-8 start byte
|
||||
return 0xFFFD, 1
|
||||
}
|
||||
if len(s) < expectedLen {
|
||||
return 0xFFFD, 1
|
||||
}
|
||||
// Decode using Go's built-in rune conversion (simple and correct)
|
||||
runes := []rune(s[:expectedLen])
|
||||
if len(runes) == 0 {
|
||||
return 0xFFFD, 1
|
||||
}
|
||||
return runes[0], expectedLen
|
||||
}
|
||||
|
||||
// isHex64 returns true if s is exactly 64 hex characters (0-9, a-f)
|
||||
func isHex64(s string) bool {
|
||||
if len(s) != 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < 64; i++ {
|
||||
c := s[i]
|
||||
if c >= '0' && c <= '9' {
|
||||
continue
|
||||
}
|
||||
if c >= 'a' && c <= 'f' {
|
||||
continue
|
||||
}
|
||||
if c >= 'A' && c <= 'F' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
135
pkg/database/unicode_normalize_wasm.go
Normal file
135
pkg/database/unicode_normalize_wasm.go
Normal file
@@ -0,0 +1,135 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package database
|
||||
|
||||
// normalizeRune maps decorative unicode characters (small caps, fraktur) back to
|
||||
// their ASCII equivalents for consistent word indexing. This ensures that text
|
||||
// written with decorative alphabets (e.g., "ᴅᴇᴀᴛʜ" or "𝔇𝔢𝔞𝔱𝔥") indexes the same
|
||||
// as regular ASCII ("death").
|
||||
//
|
||||
// Character sets normalized:
|
||||
// - Small Caps (used for DEATH-style text in Terry Pratchett tradition)
|
||||
// - Mathematical Fraktur lowercase (𝔞-𝔷)
|
||||
// - Mathematical Fraktur uppercase (𝔄-ℨ, including Letterlike Symbols block exceptions)
|
||||
func normalizeRune(r rune) rune {
|
||||
// Check small caps first (scattered codepoints)
|
||||
if mapped, ok := smallCapsToASCII[r]; ok {
|
||||
return mapped
|
||||
}
|
||||
|
||||
// Check fraktur lowercase: U+1D51E to U+1D537 (contiguous range)
|
||||
if r >= 0x1D51E && r <= 0x1D537 {
|
||||
return 'a' + (r - 0x1D51E)
|
||||
}
|
||||
|
||||
// Check fraktur uppercase main range: U+1D504 to U+1D51C (with gaps)
|
||||
if r >= 0x1D504 && r <= 0x1D51C {
|
||||
if mapped, ok := frakturUpperToASCII[r]; ok {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
|
||||
// Check fraktur uppercase exceptions from Letterlike Symbols block
|
||||
if mapped, ok := frakturLetterlikeToASCII[r]; ok {
|
||||
return mapped
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// smallCapsToASCII maps small capital letters to lowercase ASCII.
|
||||
// These are scattered across multiple Unicode blocks (IPA Extensions,
|
||||
// Phonetic Extensions, Latin Extended-D).
|
||||
var smallCapsToASCII = map[rune]rune{
|
||||
'ᴀ': 'a', // U+1D00 LATIN LETTER SMALL CAPITAL A
|
||||
'ʙ': 'b', // U+0299 LATIN LETTER SMALL CAPITAL B
|
||||
'ᴄ': 'c', // U+1D04 LATIN LETTER SMALL CAPITAL C
|
||||
'ᴅ': 'd', // U+1D05 LATIN LETTER SMALL CAPITAL D
|
||||
'ᴇ': 'e', // U+1D07 LATIN LETTER SMALL CAPITAL E
|
||||
'ꜰ': 'f', // U+A730 LATIN LETTER SMALL CAPITAL F
|
||||
'ɢ': 'g', // U+0262 LATIN LETTER SMALL CAPITAL G
|
||||
'ʜ': 'h', // U+029C LATIN LETTER SMALL CAPITAL H
|
||||
'ɪ': 'i', // U+026A LATIN LETTER SMALL CAPITAL I
|
||||
'ᴊ': 'j', // U+1D0A LATIN LETTER SMALL CAPITAL J
|
||||
'ᴋ': 'k', // U+1D0B LATIN LETTER SMALL CAPITAL K
|
||||
'ʟ': 'l', // U+029F LATIN LETTER SMALL CAPITAL L
|
||||
'ᴍ': 'm', // U+1D0D LATIN LETTER SMALL CAPITAL M
|
||||
'ɴ': 'n', // U+0274 LATIN LETTER SMALL CAPITAL N
|
||||
'ᴏ': 'o', // U+1D0F LATIN LETTER SMALL CAPITAL O
|
||||
'ᴘ': 'p', // U+1D18 LATIN LETTER SMALL CAPITAL P
|
||||
'ǫ': 'q', // U+01EB LATIN SMALL LETTER O WITH OGONEK (no true small cap Q)
|
||||
'ʀ': 'r', // U+0280 LATIN LETTER SMALL CAPITAL R
|
||||
'ꜱ': 's', // U+A731 LATIN LETTER SMALL CAPITAL S
|
||||
'ᴛ': 't', // U+1D1B LATIN LETTER SMALL CAPITAL T
|
||||
'ᴜ': 'u', // U+1D1C LATIN LETTER SMALL CAPITAL U
|
||||
'ᴠ': 'v', // U+1D20 LATIN LETTER SMALL CAPITAL V
|
||||
'ᴡ': 'w', // U+1D21 LATIN LETTER SMALL CAPITAL W
|
||||
// Note: no small cap X exists in standard use
|
||||
'ʏ': 'y', // U+028F LATIN LETTER SMALL CAPITAL Y
|
||||
'ᴢ': 'z', // U+1D22 LATIN LETTER SMALL CAPITAL Z
|
||||
}
|
||||
|
||||
// frakturUpperToASCII maps Mathematical Fraktur uppercase letters to lowercase ASCII.
|
||||
// The main range U+1D504-U+1D51C has gaps where C, H, I, R, Z use Letterlike Symbols.
|
||||
var frakturUpperToASCII = map[rune]rune{
|
||||
'𝔄': 'a', // U+1D504 MATHEMATICAL FRAKTUR CAPITAL A
|
||||
'𝔅': 'b', // U+1D505 MATHEMATICAL FRAKTUR CAPITAL B
|
||||
// C is at U+212D (Letterlike Symbols)
|
||||
'𝔇': 'd', // U+1D507 MATHEMATICAL FRAKTUR CAPITAL D
|
||||
'𝔈': 'e', // U+1D508 MATHEMATICAL FRAKTUR CAPITAL E
|
||||
'𝔉': 'f', // U+1D509 MATHEMATICAL FRAKTUR CAPITAL F
|
||||
'𝔊': 'g', // U+1D50A MATHEMATICAL FRAKTUR CAPITAL G
|
||||
// H is at U+210C (Letterlike Symbols)
|
||||
// I is at U+2111 (Letterlike Symbols)
|
||||
'𝔍': 'j', // U+1D50D MATHEMATICAL FRAKTUR CAPITAL J
|
||||
'𝔎': 'k', // U+1D50E MATHEMATICAL FRAKTUR CAPITAL K
|
||||
'𝔏': 'l', // U+1D50F MATHEMATICAL FRAKTUR CAPITAL L
|
||||
'𝔐': 'm', // U+1D510 MATHEMATICAL FRAKTUR CAPITAL M
|
||||
'𝔑': 'n', // U+1D511 MATHEMATICAL FRAKTUR CAPITAL N
|
||||
'𝔒': 'o', // U+1D512 MATHEMATICAL FRAKTUR CAPITAL O
|
||||
'𝔓': 'p', // U+1D513 MATHEMATICAL FRAKTUR CAPITAL P
|
||||
'𝔔': 'q', // U+1D514 MATHEMATICAL FRAKTUR CAPITAL Q
|
||||
// R is at U+211C (Letterlike Symbols)
|
||||
'𝔖': 's', // U+1D516 MATHEMATICAL FRAKTUR CAPITAL S
|
||||
'𝔗': 't', // U+1D517 MATHEMATICAL FRAKTUR CAPITAL T
|
||||
'𝔘': 'u', // U+1D518 MATHEMATICAL FRAKTUR CAPITAL U
|
||||
'𝔙': 'v', // U+1D519 MATHEMATICAL FRAKTUR CAPITAL V
|
||||
'𝔚': 'w', // U+1D51A MATHEMATICAL FRAKTUR CAPITAL W
|
||||
'𝔛': 'x', // U+1D51B MATHEMATICAL FRAKTUR CAPITAL X
|
||||
'𝔜': 'y', // U+1D51C MATHEMATICAL FRAKTUR CAPITAL Y
|
||||
// Z is at U+2128 (Letterlike Symbols)
|
||||
}
|
||||
|
||||
// frakturLetterlikeToASCII maps the Fraktur characters that live in the
|
||||
// Letterlike Symbols block (U+2100-U+214F) rather than Mathematical Alphanumeric Symbols.
|
||||
var frakturLetterlikeToASCII = map[rune]rune{
|
||||
'ℭ': 'c', // U+212D BLACK-LETTER CAPITAL C
|
||||
'ℌ': 'h', // U+210C BLACK-LETTER CAPITAL H
|
||||
'ℑ': 'i', // U+2111 BLACK-LETTER CAPITAL I
|
||||
'ℜ': 'r', // U+211C BLACK-LETTER CAPITAL R
|
||||
'ℨ': 'z', // U+2128 BLACK-LETTER CAPITAL Z
|
||||
}
|
||||
|
||||
// hasDecorativeUnicode checks if text contains any small caps or fraktur characters
|
||||
// that would need normalization. Used by migration to identify events needing re-indexing.
|
||||
func hasDecorativeUnicode(s string) bool {
|
||||
for _, r := range s {
|
||||
// Check small caps
|
||||
if _, ok := smallCapsToASCII[r]; ok {
|
||||
return true
|
||||
}
|
||||
// Check fraktur lowercase range
|
||||
if r >= 0x1D51E && r <= 0x1D537 {
|
||||
return true
|
||||
}
|
||||
// Check fraktur uppercase range
|
||||
if r >= 0x1D504 && r <= 0x1D51C {
|
||||
return true
|
||||
}
|
||||
// Check letterlike symbols fraktur
|
||||
if _, ok := frakturLetterlikeToASCII[r]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user