Changeset 0.30.1 (#200)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -235,10 +235,10 @@ Migrate core surfaces to `sw.*` SDK.
|
|||||||
|
|
||||||
Depends on: v0.28.5, v0.30.0.
|
Depends on: v0.28.5, v0.30.0.
|
||||||
|
|
||||||
- [ ] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
|
- [x] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
|
||||||
- [ ] Core surface migration: chat, editor, notes, settings
|
- [x] Core surface migration: chat, editor, notes, settings
|
||||||
- [ ] Phase 5 FE decomp: `import`/`export` statements
|
- [x] Phase 5 FE decomp: `import`/`export` statements
|
||||||
- [ ] Memory compaction: summarize, confidence decay, prune
|
- [x] Memory compaction: summarize, confidence decay, prune
|
||||||
|
|
||||||
### v0.30.2 — Workflow Packages
|
### v0.30.2 — Workflow Packages
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/memory"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
// MemoryHandler provides REST endpoints for memory management.
|
// MemoryHandler provides REST endpoints for memory management.
|
||||||
type MemoryHandler struct {
|
type MemoryHandler struct {
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
|
compactor *memory.Compactor
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemoryHandler creates a new memory handler.
|
// NewMemoryHandler creates a new memory handler.
|
||||||
@@ -19,6 +21,11 @@ func NewMemoryHandler(stores store.Stores) *MemoryHandler {
|
|||||||
return &MemoryHandler{stores: stores}
|
return &MemoryHandler{stores: stores}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCompactor wires the compactor for the /compact endpoint.
|
||||||
|
func (h *MemoryHandler) SetCompactor(c *memory.Compactor) {
|
||||||
|
h.compactor = c
|
||||||
|
}
|
||||||
|
|
||||||
// ListMyMemories returns the current user's memories with optional filters.
|
// ListMyMemories returns the current user's memories with optional filters.
|
||||||
// GET /api/v1/memories?status=active&query=...
|
// GET /api/v1/memories?status=active&query=...
|
||||||
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
|
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
|
||||||
@@ -254,3 +261,37 @@ func (h *MemoryHandler) BulkApprove(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"approved": approved})
|
c.JSON(http.StatusOK, gin.H{"approved": approved})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompactMemories triggers memory compaction for the current user.
|
||||||
|
// POST /api/v1/memories/compact
|
||||||
|
func (h *MemoryHandler) CompactMemories(c *gin.Context) {
|
||||||
|
if h.compactor == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compaction not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
teamID := c.GetString("team_id")
|
||||||
|
var tID *string
|
||||||
|
if teamID != "" {
|
||||||
|
tID = &teamID
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.compactor.Compact(c.Request.Context(), userID, tID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"before": result.Before,
|
||||||
|
"after": result.After,
|
||||||
|
"merged": result.Merged,
|
||||||
|
"archived": result.Archived,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ func main() {
|
|||||||
// v0.27.3: task_create tool (AI spawns sub-tasks)
|
// v0.27.3: task_create tool (AI spawns sub-tasks)
|
||||||
tools.RegisterTaskTools(stores)
|
tools.RegisterTaskTools(stores)
|
||||||
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
|
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
|
||||||
|
memCompactor := memory.NewCompactor(stores, roleResolver)
|
||||||
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
|
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
|
||||||
memScanner.Start()
|
memScanner.Start()
|
||||||
defer memScanner.Stop()
|
defer memScanner.Stop()
|
||||||
@@ -909,12 +910,14 @@ func main() {
|
|||||||
|
|
||||||
// Memory management (v0.18.0)
|
// Memory management (v0.18.0)
|
||||||
memH := handlers.NewMemoryHandler(stores)
|
memH := handlers.NewMemoryHandler(stores)
|
||||||
|
memH.SetCompactor(memCompactor)
|
||||||
protected.GET("/memories", memH.ListMyMemories)
|
protected.GET("/memories", memH.ListMyMemories)
|
||||||
protected.PUT("/memories/:id", memH.UpdateMemory)
|
protected.PUT("/memories/:id", memH.UpdateMemory)
|
||||||
protected.DELETE("/memories/:id", memH.DeleteMemory)
|
protected.DELETE("/memories/:id", memH.DeleteMemory)
|
||||||
protected.POST("/memories/:id/approve", memH.ApproveMemory)
|
protected.POST("/memories/:id/approve", memH.ApproveMemory)
|
||||||
protected.POST("/memories/:id/reject", memH.RejectMemory)
|
protected.POST("/memories/:id/reject", memH.RejectMemory)
|
||||||
protected.GET("/memories/count", memH.MemoryCount)
|
protected.GET("/memories/count", memH.MemoryCount)
|
||||||
|
protected.POST("/memories/compact", memH.CompactMemories)
|
||||||
|
|
||||||
// Teams (user: my teams)
|
// Teams (user: my teams)
|
||||||
teams := handlers.NewTeamHandler(stores, keyResolver)
|
teams := handlers.NewTeamHandler(stores, keyResolver)
|
||||||
|
|||||||
157
server/memory/compactor.go
Normal file
157
server/memory/compactor.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compactionThreshold is the minimum number of active memories before
|
||||||
|
// compaction is considered worthwhile for a user.
|
||||||
|
const compactionThreshold = 30
|
||||||
|
|
||||||
|
// compactionPrompt instructs the utility model to merge related memories.
|
||||||
|
const compactionPrompt = `You are a memory compaction assistant. Given a set of user memories (key-value facts), merge related or redundant entries into fewer, more precise facts.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Combine facts about the same topic into a single entry
|
||||||
|
- Remove duplicates, keeping the most specific/recent version
|
||||||
|
- Preserve all unique information — do not drop facts
|
||||||
|
- Keep keys short (2-5 words, lowercase)
|
||||||
|
- Keep values concise (1-2 sentences max)
|
||||||
|
- Set confidence based on how well-supported the merged fact is
|
||||||
|
|
||||||
|
Respond ONLY with a JSON array of merged facts:
|
||||||
|
[{"key": "...", "value": "...", "confidence": 0.0-1.0}]
|
||||||
|
|
||||||
|
If no merging is possible, return the original facts unchanged.`
|
||||||
|
|
||||||
|
// Compactor merges related memories to reduce count and improve quality.
|
||||||
|
type Compactor struct {
|
||||||
|
stores store.Stores
|
||||||
|
roleResolver *roles.Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCompactor creates a memory compaction service.
|
||||||
|
func NewCompactor(stores store.Stores, rr *roles.Resolver) *Compactor {
|
||||||
|
return &Compactor{stores: stores, roleResolver: rr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactResult holds the outcome of a compaction run.
|
||||||
|
type CompactResult struct {
|
||||||
|
Before int `json:"before"`
|
||||||
|
After int `json:"after"`
|
||||||
|
Merged int `json:"merged"`
|
||||||
|
Archived int `json:"archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact loads active memories for a user, sends them to the utility model
|
||||||
|
// for merging, then upserts merged entries and archives originals.
|
||||||
|
func (c *Compactor) Compact(ctx context.Context, userID string, teamID *string) (*CompactResult, error) {
|
||||||
|
if c.stores.Memories == nil {
|
||||||
|
return nil, fmt.Errorf("memory store not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all active user-scope memories
|
||||||
|
memories, err := c.stores.Memories.List(ctx, models.MemoryFilter{
|
||||||
|
Scope: models.MemoryScopeUser,
|
||||||
|
OwnerID: userID,
|
||||||
|
Status: models.MemoryStatusActive,
|
||||||
|
Limit: 500,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list memories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(memories) < compactionThreshold {
|
||||||
|
return &CompactResult{Before: len(memories), After: len(memories)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build memory text for the model
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, m := range memories {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. [%s] %s (confidence: %.2f)\n", i+1, m.Key, m.Value, m.Confidence))
|
||||||
|
}
|
||||||
|
|
||||||
|
apiMessages := []providers.Message{
|
||||||
|
{Role: "system", Content: compactionPrompt},
|
||||||
|
{Role: "user", Content: fmt.Sprintf("Compact these %d memories:\n\n%s", len(memories), sb.String())},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.roleResolver.Complete(ctx, "utility", userID, teamID, apiMessages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compaction completion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse merged facts
|
||||||
|
merged, err := parseExtractionResponse(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse compaction response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(merged) == 0 || len(merged) >= len(memories) {
|
||||||
|
// Model returned nothing useful or didn't reduce — skip
|
||||||
|
log.Printf("🧠 memory compaction: user %s — model returned %d facts (was %d), skipping",
|
||||||
|
userID, len(merged), len(memories))
|
||||||
|
return &CompactResult{Before: len(memories), After: len(memories)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive all original memories
|
||||||
|
archived := 0
|
||||||
|
for _, m := range memories {
|
||||||
|
if err := c.stores.Memories.Archive(ctx, m.ID); err == nil {
|
||||||
|
archived++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert merged memories
|
||||||
|
for _, fact := range merged {
|
||||||
|
mem := &models.Memory{
|
||||||
|
Scope: models.MemoryScopeUser,
|
||||||
|
OwnerID: userID,
|
||||||
|
Key: fact.Key,
|
||||||
|
Value: fact.Value,
|
||||||
|
Confidence: fact.Confidence,
|
||||||
|
Status: models.MemoryStatusActive,
|
||||||
|
}
|
||||||
|
if err := c.stores.Memories.Upsert(ctx, mem); err != nil {
|
||||||
|
log.Printf("⚠ memory compaction upsert failed: key=%s: %v", fact.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🧠 memory compaction: user %s — %d → %d memories (archived %d)",
|
||||||
|
userID, len(memories), len(merged), archived)
|
||||||
|
|
||||||
|
return &CompactResult{
|
||||||
|
Before: len(memories),
|
||||||
|
After: len(merged),
|
||||||
|
Merged: len(merged),
|
||||||
|
Archived: archived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCompactionResponse parses the model's JSON array response.
|
||||||
|
// Reuses the same extractedFact struct from extractor.go.
|
||||||
|
func parseCompactionResponse(content string) ([]extractedFact, error) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
// Strip markdown code fences if present
|
||||||
|
if strings.HasPrefix(content, "```") {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
if len(lines) > 2 {
|
||||||
|
content = strings.Join(lines[1:len(lines)-1], "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var facts []extractedFact
|
||||||
|
if err := json.Unmarshal([]byte(content), &facts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return facts, nil
|
||||||
|
}
|
||||||
@@ -95,32 +95,29 @@ func (s *Scanner) scan() {
|
|||||||
// If key is missing (err != nil or val == nil), extraction is enabled.
|
// If key is missing (err != nil or val == nil), extraction is enabled.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Extraction ──
|
||||||
candidates, err := s.findCandidates(ctx)
|
candidates, err := s.findCandidates(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠ memory scanner: find candidates: %v", err)
|
log.Printf("⚠ memory scanner: find candidates: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(candidates) == 0 {
|
if len(candidates) > 0 {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Semaphore for concurrency control
|
|
||||||
sem := make(chan struct{}, s.cfg.Concurrency)
|
sem := make(chan struct{}, s.cfg.Concurrency)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
key := c.ChannelID + ":" + c.UserID
|
key := c.ChannelID + ":" + c.UserID
|
||||||
if _, loaded := s.inFlight.LoadOrStore(key, true); loaded {
|
if _, loaded := s.inFlight.LoadOrStore(key, true); loaded {
|
||||||
continue // already in progress
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sem <- struct{}{} // acquire
|
sem <- struct{}{}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
|
||||||
go func(cand candidate) {
|
go func(cand candidate) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }() // release
|
defer func() { <-sem }()
|
||||||
defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID)
|
defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID)
|
||||||
|
|
||||||
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||||
@@ -136,6 +133,45 @@ func (s *Scanner) scan() {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.30.1: Compaction (decay + prune) ──
|
||||||
|
// Runs after extraction on every tick. Gated by memory_compaction_enabled.
|
||||||
|
s.runCompaction(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCompaction applies confidence decay and pruning to stale memories.
|
||||||
|
func (s *Scanner) runCompaction(ctx context.Context) {
|
||||||
|
if s.stores.Memories == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check compaction kill switch (default: enabled)
|
||||||
|
if s.stores.GlobalConfig != nil {
|
||||||
|
val, err := s.stores.GlobalConfig.Get(ctx, "memory_compaction_enabled")
|
||||||
|
if err == nil && val != nil {
|
||||||
|
if enabled, ok := val["value"].(bool); ok && !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decay: reduce confidence for memories not updated in 7 days
|
||||||
|
olderThan := time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
decayed, err := s.stores.Memories.DecayConfidence(ctx, olderThan, 0.9)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ memory compaction: decay failed: %v", err)
|
||||||
|
} else if decayed > 0 {
|
||||||
|
log.Printf("🧠 memory compaction: decayed %d memories", decayed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune: archive memories with confidence below 0.15
|
||||||
|
pruned, err := s.stores.Memories.Prune(ctx, 0.15)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ memory compaction: prune failed: %v", err)
|
||||||
|
} else if pruned > 0 {
|
||||||
|
log.Printf("🧠 memory compaction: pruned %d memories", pruned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// findCandidates queries for channels with enough new activity since last extraction.
|
// findCandidates queries for channels with enough new activity since last extraction.
|
||||||
//
|
//
|
||||||
// Fixes from v0.28.0 audit:
|
// Fixes from v0.28.0 audit:
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/api.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/api.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}">
|
<script type="module" nonce="{{.CSPNonce}}">
|
||||||
|
|||||||
@@ -209,7 +209,7 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-admin.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-admin.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-handlers.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-handlers.js?v={{.Version}}"></script>
|
||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
||||||
|
|||||||
@@ -515,6 +515,7 @@ window.addEventListener('unhandledrejection', function(e) {
|
|||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-settings.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-settings.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-admin.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-admin.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
|
||||||
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/note-panel.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notes.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notes.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/note-graph.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/note-graph.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
|
||||||
@@ -522,12 +523,12 @@ window.addEventListener('unhandledrejection', function(e) {
|
|||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/memory-ui.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/memory-ui.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/persona-kb.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/persona-kb.js?v={{$.Version}}"></script>
|
||||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/projects-ui.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/projects-ui.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/channel-models.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/channel-models.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notification-prefs.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notification-prefs.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notifications.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notifications.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
|
||||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/tokens.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/tokens.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/extensions.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/extensions.js?v={{.Version}}"></script>
|
||||||
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-panel.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notes.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notes.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-graph.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-graph.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/panels.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/panels.js?v={{.Version}}"></script>
|
||||||
{{/* debug.js now loaded in base.html for all surfaces */}}
|
{{/* debug.js now loaded in base.html for all surfaces */}}
|
||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
|
||||||
<script nonce="{{.CSPNonce}}">
|
<script nonce="{{.CSPNonce}}">
|
||||||
// Notes surface boot: open notes panel in standalone mode
|
// Notes surface boot: open notes panel in standalone mode
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
{{/* Scripts */}}
|
{{/* Scripts */}}
|
||||||
{{define "scripts-settings"}}
|
{{define "scripts-settings"}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
|
||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
||||||
|
|||||||
@@ -301,3 +301,36 @@ func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID
|
|||||||
`, channelID, userID, lastMessageID, count)
|
`, channelID, userID, lastMessageID, count)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.30.1 — Memory Compaction ────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) {
|
||||||
|
res, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE memories
|
||||||
|
SET confidence = confidence * $1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND updated_at < $2::timestamptz
|
||||||
|
AND confidence > 0.1
|
||||||
|
`, decayFactor, olderThan)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) {
|
||||||
|
res, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE memories
|
||||||
|
SET status = 'archived',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND confidence < $1
|
||||||
|
`, belowConfidence)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,3 +285,36 @@ func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID
|
|||||||
`, store.NewID(), channelID, userID, lastMessageID, count)
|
`, store.NewID(), channelID, userID, lastMessageID, count)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.30.1 — Memory Compaction ────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) {
|
||||||
|
res, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE memories
|
||||||
|
SET confidence = confidence * ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND updated_at < ?
|
||||||
|
AND confidence > 0.1
|
||||||
|
`, decayFactor, olderThan)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) {
|
||||||
|
res, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE memories
|
||||||
|
SET status = 'archived',
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND confidence < ?
|
||||||
|
`, belowConfidence)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,4 +64,15 @@ type MemoryStore interface {
|
|||||||
|
|
||||||
// UpsertExtractionLog creates or updates the memory extraction log entry.
|
// UpsertExtractionLog creates or updates the memory extraction log entry.
|
||||||
UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error
|
UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error
|
||||||
|
|
||||||
|
// ── v0.30.1 — Memory Compaction ──
|
||||||
|
|
||||||
|
// DecayConfidence reduces confidence for active memories not updated since olderThan.
|
||||||
|
// Formula: new_confidence = confidence * decayFactor. Skips memories with confidence <= 0.1.
|
||||||
|
// Returns the number of affected rows.
|
||||||
|
DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error)
|
||||||
|
|
||||||
|
// Prune archives active memories whose confidence has dropped below the threshold.
|
||||||
|
// Returns the number of archived rows.
|
||||||
|
Prune(ctx context.Context, belowConfidence float64) (int, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -667,8 +667,8 @@ function _initAdminListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
|
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
|
||||||
if (typeof Events !== 'undefined') {
|
if (typeof sw !== 'undefined') {
|
||||||
Events.on('role.fallback', (payload) => {
|
sw.on('role.fallback', (payload) => {
|
||||||
const banner = document.getElementById('roleFallbackBanner');
|
const banner = document.getElementById('roleFallbackBanner');
|
||||||
if (!banner) return;
|
if (!banner) return;
|
||||||
const msg = payload?.message || 'A model role is using its fallback provider.';
|
const msg = payload?.message || 'A model role is using its fallback provider.';
|
||||||
@@ -704,8 +704,8 @@ async function deleteAdminExtension(id, name) {
|
|||||||
var _extEditors = {};
|
var _extEditors = {};
|
||||||
|
|
||||||
// Listen for theme changes to update CM6 editors
|
// Listen for theme changes to update CM6 editors
|
||||||
if (typeof Events !== 'undefined') {
|
if (typeof sw !== 'undefined') {
|
||||||
Events.on('theme.changed', (data) => {
|
sw.on('theme.changed', (data) => {
|
||||||
const isDark = data.resolved === 'dark';
|
const isDark = data.resolved === 'dark';
|
||||||
for (const editors of Object.values(_extEditors)) {
|
for (const editors of Object.values(_extEditors)) {
|
||||||
editors.manifest?.setDarkMode(isDark);
|
editors.manifest?.setDarkMode(isDark);
|
||||||
@@ -713,8 +713,7 @@ if (typeof Events !== 'undefined') {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for keymap changes to update CM6 editors in real time
|
sw.on('keymap.changed', (data) => {
|
||||||
Events.on('keymap.changed', (data) => {
|
|
||||||
const mode = data.mode || 'standard';
|
const mode = data.mode || 'standard';
|
||||||
for (const editors of Object.values(_extEditors)) {
|
for (const editors of Object.values(_extEditors)) {
|
||||||
editors.manifest?.setKeymap(mode);
|
editors.manifest?.setKeymap(mode);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function startApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UI.updateUser();
|
UI.updateUser();
|
||||||
UI.showAdminButton(API.isAdmin);
|
UI.showAdminButton(sw.isAdmin);
|
||||||
UI.restoreAppearance();
|
UI.restoreAppearance();
|
||||||
|
|
||||||
// Load models for all surfaces (editor needs them for model selector)
|
// Load models for all surfaces (editor needs them for model selector)
|
||||||
@@ -100,7 +100,7 @@ async function startApp() {
|
|||||||
// Connect EventBus WebSocket (non-blocking)
|
// Connect EventBus WebSocket (non-blocking)
|
||||||
try {
|
try {
|
||||||
Events.connect((window.__BASE__ || '') + '/ws');
|
Events.connect((window.__BASE__ || '') + '/ws');
|
||||||
Events.on('ws.connected', () => {
|
sw.on('ws.connected', () => {
|
||||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -195,8 +195,8 @@ async function _startChatSurface() {
|
|||||||
initListeners();
|
initListeners();
|
||||||
|
|
||||||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||||
Events.on('role.fallback', (payload) => {
|
sw.on('role.fallback', (payload) => {
|
||||||
if (App.user?.role !== 'admin') return;
|
if (!sw.isAdmin) return;
|
||||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||||
showFallbackBanner(data);
|
showFallbackBanner(data);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ async function selectChat(chatId) {
|
|||||||
updateChatTokenCount();
|
updateChatTokenCount();
|
||||||
|
|
||||||
// Notify surfaces and extensions about channel switch (v0.21.6)
|
// Notify surfaces and extensions about channel switch (v0.21.6)
|
||||||
Events.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
|
sw.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
|
||||||
|
|
||||||
// Update browser URL to reflect selected chat
|
// Update browser URL to reflect selected chat
|
||||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
|
||||||
@@ -437,7 +437,7 @@ async function newChat() {
|
|||||||
}
|
}
|
||||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||||
// Notify surfaces — no channel selected (v0.21.6)
|
// Notify surfaces — no channel selected (v0.21.6)
|
||||||
Events.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
|
sw.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
|
||||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +537,7 @@ async function newGroupChat() {
|
|||||||
App.chats.unshift(chat);
|
App.chats.unshift(chat);
|
||||||
App.setActive(chat.id, 'group');
|
App.setActive(chat.id, 'group');
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
|
sw.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
|
||||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
||||||
|
|
||||||
// Load the channel model roster (populated by participant auto-add)
|
// Load the channel model roster (populated by participant auto-add)
|
||||||
@@ -708,7 +708,7 @@ async function sendMessage() {
|
|||||||
App.setActive(chat.id, 'direct');
|
App.setActive(chat.id, 'direct');
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
// Notify surfaces about new chat creation (v0.21.6)
|
// Notify surfaces about new chat creation (v0.21.6)
|
||||||
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
|
sw.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
|
||||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
||||||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||||
}
|
}
|
||||||
@@ -1120,7 +1120,7 @@ function _initChatListeners() {
|
|||||||
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
|
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
|
||||||
// When a persona @mentions another persona, the follow-up completion
|
// When a persona @mentions another persona, the follow-up completion
|
||||||
// runs server-side and delivers the result via WS, not SSE.
|
// runs server-side and delivers the result via WS, not SSE.
|
||||||
Events.on('message.created', (payload) => {
|
sw.on('message.created', (payload) => {
|
||||||
if (!payload || !payload.channel_id) return;
|
if (!payload || !payload.channel_id) return;
|
||||||
|
|
||||||
// v0.23.2: If this channel isn't in our sidebar yet, fetch and add it
|
// v0.23.2: If this channel isn't in our sidebar yet, fetch and add it
|
||||||
@@ -1170,7 +1170,7 @@ function _initChatListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Typing indicators from persona chain activity
|
// Typing indicators from persona chain activity
|
||||||
Events.on('typing.start', (payload) => {
|
sw.on('typing.start', (payload) => {
|
||||||
if (!payload || payload.channel_id !== App.activeId) return;
|
if (!payload || payload.channel_id !== App.activeId) return;
|
||||||
if (payload.participant_type !== 'persona') return;
|
if (payload.participant_type !== 'persona') return;
|
||||||
|
|
||||||
@@ -1192,16 +1192,16 @@ function _initChatListeners() {
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
Events.on('typing.stop', (payload) => {
|
sw.on('typing.stop', (payload) => {
|
||||||
if (!payload || payload.channel_id !== App.activeId) return;
|
if (!payload || payload.channel_id !== App.activeId) return;
|
||||||
document.getElementById('typingIndicator')?.remove();
|
document.getElementById('typingIndicator')?.remove();
|
||||||
document.getElementById('userTypingIndicator')?.remove();
|
document.getElementById('userTypingIndicator')?.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
// v0.23.2: Human typing indicator from other participants
|
// v0.23.2: Human typing indicator from other participants
|
||||||
Events.on('typing.user', (payload) => {
|
sw.on('typing.user', (payload) => {
|
||||||
if (!payload || payload.channel_id !== App.activeId) return;
|
if (!payload || payload.channel_id !== App.activeId) return;
|
||||||
if (payload.user_id === API.user?.id) return; // ignore self
|
if (payload.user_id === sw.user?.id) return; // ignore self
|
||||||
|
|
||||||
// Show or refresh typing indicator
|
// Show or refresh typing indicator
|
||||||
let typing = document.getElementById('userTypingIndicator');
|
let typing = document.getElementById('userTypingIndicator');
|
||||||
@@ -1231,7 +1231,7 @@ function _initChatListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// v0.23.2: User @mention notification
|
// v0.23.2: User @mention notification
|
||||||
Events.on('user.mentioned', (payload) => {
|
sw.on('user.mentioned', (payload) => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const channelId = payload.channel_id;
|
const channelId = payload.channel_id;
|
||||||
// Increment unread badge on the channel sidebar item
|
// Increment unread badge on the channel sidebar item
|
||||||
@@ -1246,14 +1246,14 @@ function _initChatListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// v0.23.2: Live presence updates for sidebar online dots
|
// v0.23.2: Live presence updates for sidebar online dots
|
||||||
Events.on('user.presence', (payload) => {
|
sw.on('user.presence', (payload) => {
|
||||||
if (!payload || !payload.user_id) return;
|
if (!payload || !payload.user_id) return;
|
||||||
App.presence[payload.user_id] = payload.status || 'offline';
|
App.presence[payload.user_id] = payload.status || 'offline';
|
||||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||||
});
|
});
|
||||||
|
|
||||||
// v0.27.0: Workflow stage advanced — refresh stage bar + auto-switch persona
|
// v0.27.0: Workflow stage advanced — refresh stage bar + auto-switch persona
|
||||||
Events.on('workflow.advanced', (payload) => {
|
sw.on('workflow.advanced', (payload) => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const ac = App.activeConversation;
|
const ac = App.activeConversation;
|
||||||
if (ac && ac.id === payload.channel_id) {
|
if (ac && ac.id === payload.channel_id) {
|
||||||
@@ -1264,7 +1264,7 @@ function _initChatListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// v0.27.0: Workflow completed — refresh stage bar
|
// v0.27.0: Workflow completed — refresh stage bar
|
||||||
Events.on('workflow.completed', (payload) => {
|
sw.on('workflow.completed', (payload) => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const ac = App.activeConversation;
|
const ac = App.activeConversation;
|
||||||
if (ac && ac.id === payload.channel_id) {
|
if (ac && ac.id === payload.channel_id) {
|
||||||
@@ -1274,9 +1274,9 @@ function _initChatListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// v0.27.0: Workflow assigned — refresh queue + toast
|
// v0.27.0: Workflow assigned — refresh queue + toast
|
||||||
Events.on('workflow.assigned', (payload) => {
|
sw.on('workflow.assigned', (payload) => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
if (payload.assigned_to === (API.user && API.user.id)) {
|
if (payload.assigned_to === sw.user?.id) {
|
||||||
UI.toast('Workflow stage "' + (payload.stage_name || '?') + '" assigned to you', 'info');
|
UI.toast('Workflow stage "' + (payload.stage_name || '?') + '" assigned to you', 'info');
|
||||||
}
|
}
|
||||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
const name = window.prompt('Workspace name:');
|
const name = window.prompt('Workspace name:');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
const userId = API.user?.id || window.__USER__?.id;
|
const userId = sw.user?.id || window.__USER__?.id;
|
||||||
if (!userId) throw new Error('Not authenticated');
|
if (!userId) throw new Error('Not authenticated');
|
||||||
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
|
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
|
||||||
const newId = resp.id || resp.data?.id;
|
const newId = resp.id || resp.data?.id;
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Creating…';
|
btn.textContent = 'Creating…';
|
||||||
try {
|
try {
|
||||||
const userId = API.user?.id || window.__USER__?.id;
|
const userId = sw.user?.id || window.__USER__?.id;
|
||||||
if (!userId) throw new Error('Not authenticated');
|
if (!userId) throw new Error('Not authenticated');
|
||||||
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
|
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
|
||||||
const newId = resp.id || resp.data?.id;
|
const newId = resp.id || resp.data?.id;
|
||||||
|
|||||||
788
src/js/note-panel.js
Normal file
788
src/js/note-panel.js
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – NotePanel Component
|
||||||
|
// ==========================================
|
||||||
|
// v0.30.1: Mountable, self-contained notes panel instances.
|
||||||
|
// Follows the ChatPane pattern: createComponentRegistry + componentMixin.
|
||||||
|
// NotePanel.primary is the backward-compat bridge (singleton used by notes.js shim).
|
||||||
|
//
|
||||||
|
// Exports: window.NotePanel
|
||||||
|
|
||||||
|
|
||||||
|
const NotePanel = {
|
||||||
|
...createComponentRegistry('NotePanel'),
|
||||||
|
|
||||||
|
create(opts) {
|
||||||
|
const id = opts.id || 'notes-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
||||||
|
const container = opts.container;
|
||||||
|
if (!container) {
|
||||||
|
console.error('[NotePanel] container is required');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = componentMixin({
|
||||||
|
id,
|
||||||
|
container,
|
||||||
|
projectId: opts.projectId || null,
|
||||||
|
|
||||||
|
// ── State (was module-level in notes.js) ──
|
||||||
|
_editingNoteId: null,
|
||||||
|
_notesSelectMode: false,
|
||||||
|
_selectedNoteIds: new Set(),
|
||||||
|
_notesSort: 'updated_desc',
|
||||||
|
_noteEditor: null,
|
||||||
|
_currentNote: null,
|
||||||
|
_searchTimer: null,
|
||||||
|
|
||||||
|
// ── DOM query helper ──
|
||||||
|
$(sel) { return this.container.querySelector(sel); },
|
||||||
|
$$(sel) { return this.container.querySelectorAll(sel); },
|
||||||
|
|
||||||
|
// ── Notes List ──
|
||||||
|
|
||||||
|
async loadNotesList(folder, searchQuery) {
|
||||||
|
const list = this.$('#notesList');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '<div class="loading">Loading\u2026</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (searchQuery) {
|
||||||
|
data = await API.searchNotes(searchQuery);
|
||||||
|
const results = data.data || [];
|
||||||
|
if (results.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-hint">No results found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = results.map(n => this._noteListItem(n, true)).join('');
|
||||||
|
} else {
|
||||||
|
const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
|
||||||
|
data = await API.listNotes(100, 0, folderVal, '', this._notesSort);
|
||||||
|
const notes = data.data || [];
|
||||||
|
if (notes.length === 0) {
|
||||||
|
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = notes.map(n => this._noteListItem(n, false)).join('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_noteListItem(note, isSearch) {
|
||||||
|
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
|
||||||
|
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
|
||||||
|
const preview = isSearch && note.headline
|
||||||
|
? `<div class="note-headline">${this._highlightHeadline(note.headline)}</div>`
|
||||||
|
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
|
||||||
|
const time = _relativeTime(note.updated_at);
|
||||||
|
const checked = this._selectedNoteIds.has(note.id) ? 'checked' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="note-item ${this._selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
|
||||||
|
<div class="note-select-col" style="display:${this._notesSelectMode ? '' : 'none'}">
|
||||||
|
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
|
||||||
|
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
|
||||||
|
</div>
|
||||||
|
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
|
||||||
|
<div class="note-item-header">
|
||||||
|
<span class="note-item-title">${esc(note.title)}</span>
|
||||||
|
<span class="note-item-time">${time}</span>
|
||||||
|
</div>
|
||||||
|
${preview}
|
||||||
|
<div class="note-item-meta">${folder}${tags}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_highlightHeadline(headline) {
|
||||||
|
const escaped = esc(headline);
|
||||||
|
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Multi-select ──
|
||||||
|
|
||||||
|
_enterSelectMode() {
|
||||||
|
this._notesSelectMode = true;
|
||||||
|
this._selectedNoteIds.clear();
|
||||||
|
const bar = this.$('#notesSelectionBar');
|
||||||
|
if (bar) bar.style.display = '';
|
||||||
|
this.$$('.note-select-col').forEach(el => el.style.display = '');
|
||||||
|
this._updateSelectedCount();
|
||||||
|
},
|
||||||
|
|
||||||
|
_exitSelectMode() {
|
||||||
|
this._notesSelectMode = false;
|
||||||
|
this._selectedNoteIds.clear();
|
||||||
|
const bar = this.$('#notesSelectionBar');
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
|
this.$$('.note-select-col').forEach(el => el.style.display = 'none');
|
||||||
|
this.$$('.note-item.selected').forEach(el => el.classList.remove('selected'));
|
||||||
|
this.$$('.note-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
const sa = this.$('#notesSelectAll');
|
||||||
|
if (sa) sa.checked = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
_toggleNoteSelect(noteId, forceState) {
|
||||||
|
if (!this._notesSelectMode) {
|
||||||
|
this._enterSelectMode();
|
||||||
|
}
|
||||||
|
const has = this._selectedNoteIds.has(noteId);
|
||||||
|
const newState = forceState !== undefined ? forceState : !has;
|
||||||
|
|
||||||
|
if (newState) this._selectedNoteIds.add(noteId);
|
||||||
|
else this._selectedNoteIds.delete(noteId);
|
||||||
|
|
||||||
|
const item = this.container.querySelector(`.note-item[data-note-id="${noteId}"]`);
|
||||||
|
if (item) {
|
||||||
|
item.classList.toggle('selected', newState);
|
||||||
|
const cb = item.querySelector('.note-checkbox');
|
||||||
|
if (cb) cb.checked = newState;
|
||||||
|
}
|
||||||
|
this._updateSelectedCount();
|
||||||
|
},
|
||||||
|
|
||||||
|
_toggleSelectAll(checked) {
|
||||||
|
this.$$('.note-checkbox').forEach(cb => {
|
||||||
|
const cbId = cb.dataset.id;
|
||||||
|
if (checked) this._selectedNoteIds.add(cbId);
|
||||||
|
else this._selectedNoteIds.delete(cbId);
|
||||||
|
cb.checked = checked;
|
||||||
|
cb.closest('.note-item')?.classList.toggle('selected', checked);
|
||||||
|
});
|
||||||
|
this._updateSelectedCount();
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateSelectedCount() {
|
||||||
|
const el = this.$('#notesSelectedCount');
|
||||||
|
if (el) el.textContent = this._selectedNoteIds.size;
|
||||||
|
},
|
||||||
|
|
||||||
|
async _bulkDeleteSelected() {
|
||||||
|
if (this._selectedNoteIds.size === 0) return;
|
||||||
|
const count = this._selectedNoteIds.size;
|
||||||
|
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.bulkDeleteNotes([...this._selectedNoteIds]);
|
||||||
|
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
|
||||||
|
this._exitSelectMode();
|
||||||
|
await this.loadNotesList();
|
||||||
|
await this.loadNoteFolders();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Folders ──
|
||||||
|
|
||||||
|
async loadNoteFolders() {
|
||||||
|
const sel = this.$('#notesFolderFilter');
|
||||||
|
if (!sel) return;
|
||||||
|
try {
|
||||||
|
const data = await API.listNoteFolders();
|
||||||
|
const folders = data.folders || [];
|
||||||
|
sel.innerHTML = '<option value="">All folders</option>';
|
||||||
|
folders.forEach(f => {
|
||||||
|
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
|
||||||
|
});
|
||||||
|
} catch (e) { /* non-critical */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── View switching ──
|
||||||
|
|
||||||
|
showNotesList() {
|
||||||
|
const lv = this.$('#notesListView');
|
||||||
|
const ev = this.$('#notesEditorView');
|
||||||
|
const gv = this.$('#notesGraphView');
|
||||||
|
if (lv) lv.style.display = '';
|
||||||
|
if (ev) ev.style.display = 'none';
|
||||||
|
if (gv) gv.style.display = 'none';
|
||||||
|
this._editingNoteId = null;
|
||||||
|
this._destroyNoteEditor();
|
||||||
|
const list = this.$('#notesList');
|
||||||
|
if (list) list.innerHTML = '<div class="loading">Loading\u2026</div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Note Editor ──
|
||||||
|
|
||||||
|
copyNoteContent() {
|
||||||
|
if (!this._currentNote) return;
|
||||||
|
const text = `# ${this._currentNote.title || ''}\n\n${this._currentNote.content || ''}`;
|
||||||
|
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async openNoteEditor(noteId) {
|
||||||
|
const lv = this.$('#notesListView');
|
||||||
|
const ev = this.$('#notesEditorView');
|
||||||
|
if (lv) lv.style.display = 'none';
|
||||||
|
if (ev) ev.style.display = '';
|
||||||
|
|
||||||
|
if (noteId) {
|
||||||
|
this._editingNoteId = noteId;
|
||||||
|
try {
|
||||||
|
this._currentNote = await API.getNote(noteId);
|
||||||
|
this._populateEditFields(this._currentNote);
|
||||||
|
this._showNoteReadMode();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to load note: ' + e.message, 'error');
|
||||||
|
this.showNotesList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._editingNoteId = null;
|
||||||
|
this._currentNote = null;
|
||||||
|
this._clearEditFields();
|
||||||
|
this._showNoteEditMode();
|
||||||
|
this.$('#noteEditorTitle')?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_populateEditFields(note) {
|
||||||
|
const t = this.$('#noteEditorTitle');
|
||||||
|
const f = this.$('#noteEditorFolder');
|
||||||
|
const g = this.$('#noteEditorTags');
|
||||||
|
if (t) t.value = note.title || '';
|
||||||
|
if (f) f.value = note.folder_path || '';
|
||||||
|
if (g) g.value = (note.tags || []).join(', ');
|
||||||
|
this._setNoteEditorContent(note.content || '');
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearEditFields() {
|
||||||
|
const t = this.$('#noteEditorTitle');
|
||||||
|
const f = this.$('#noteEditorFolder');
|
||||||
|
const g = this.$('#noteEditorTags');
|
||||||
|
if (t) t.value = '';
|
||||||
|
if (f) f.value = '';
|
||||||
|
if (g) g.value = '';
|
||||||
|
this._setNoteEditorContent('');
|
||||||
|
},
|
||||||
|
|
||||||
|
_getNoteEditorContent() {
|
||||||
|
if (this._noteEditor) return this._noteEditor.getValue();
|
||||||
|
const ta = this.container.querySelector('#noteEditorContentContainer textarea');
|
||||||
|
return ta ? ta.value : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
_setNoteEditorContent(text) {
|
||||||
|
if (this._noteEditor) {
|
||||||
|
this._noteEditor.setValue(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = this.$('#noteEditorContentContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (window.CM?.noteEditor) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
this._noteEditor = CM.noteEditor(container, {
|
||||||
|
value: text,
|
||||||
|
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||||
|
onChange: null,
|
||||||
|
onLink: (title) => {
|
||||||
|
this._navigateToLinkedNote(title);
|
||||||
|
},
|
||||||
|
linkCompleter: async (query) => {
|
||||||
|
if (!query || query.length < 1) return [];
|
||||||
|
try {
|
||||||
|
const resp = await API.searchNoteTitles(query, 8);
|
||||||
|
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
|
||||||
|
} catch { return []; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!container.querySelector('textarea')) {
|
||||||
|
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>';
|
||||||
|
}
|
||||||
|
const ta = container.querySelector('textarea');
|
||||||
|
if (ta) ta.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _navigateToLinkedNote(title) {
|
||||||
|
try {
|
||||||
|
const resp = await API.searchNoteTitles(title, 1);
|
||||||
|
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
|
||||||
|
if (match) {
|
||||||
|
await this.openNoteEditor(match.id);
|
||||||
|
} else {
|
||||||
|
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
|
||||||
|
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
|
||||||
|
await this.openNoteEditor(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_destroyNoteEditor() {
|
||||||
|
if (this._noteEditor) {
|
||||||
|
this._noteEditor.destroy();
|
||||||
|
this._noteEditor = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_showNoteReadMode() {
|
||||||
|
if (!this._currentNote) return;
|
||||||
|
const n = this._currentNote;
|
||||||
|
|
||||||
|
const titleEl = this.$('#noteReadTitle');
|
||||||
|
if (titleEl) titleEl.textContent = n.title || '';
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
|
||||||
|
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||||
|
const metaEl = this.$('#noteReadMeta');
|
||||||
|
if (metaEl) metaEl.innerHTML = parts.join(' ');
|
||||||
|
|
||||||
|
const noteReadEl = this.$('#noteReadContent');
|
||||||
|
if (noteReadEl) {
|
||||||
|
noteReadEl.innerHTML = formatMessage(n.content || '');
|
||||||
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
|
||||||
|
this._renderWikilinksInReadMode(noteReadEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delBtn = this.$('#noteDeleteBtn2');
|
||||||
|
if (delBtn) delBtn.style.display = this._editingNoteId ? '' : 'none';
|
||||||
|
|
||||||
|
const rm = this.$('#noteReadMode');
|
||||||
|
const em = this.$('#noteEditMode');
|
||||||
|
const eb = this.$('#noteEditBtn');
|
||||||
|
const pb = this.$('#notePreviewBtn');
|
||||||
|
if (rm) rm.style.display = '';
|
||||||
|
if (em) em.style.display = 'none';
|
||||||
|
if (eb) eb.style.display = '';
|
||||||
|
if (pb) pb.style.display = 'none';
|
||||||
|
|
||||||
|
if (this._editingNoteId) this._loadBacklinks(this._editingNoteId);
|
||||||
|
this._updateDailyNav(this._currentNote);
|
||||||
|
},
|
||||||
|
|
||||||
|
_showNoteEditMode() {
|
||||||
|
const rm = this.$('#noteReadMode');
|
||||||
|
const em = this.$('#noteEditMode');
|
||||||
|
const db = this.$('#noteDeleteBtn');
|
||||||
|
const cb = this.$('#noteCancelEditBtn');
|
||||||
|
const eb = this.$('#noteEditBtn');
|
||||||
|
const pb = this.$('#notePreviewBtn');
|
||||||
|
if (rm) rm.style.display = 'none';
|
||||||
|
if (em) em.style.display = '';
|
||||||
|
if (db) db.style.display = this._editingNoteId ? '' : 'none';
|
||||||
|
if (cb) cb.style.display = this._editingNoteId ? '' : 'none';
|
||||||
|
if (eb) eb.style.display = 'none';
|
||||||
|
if (pb) pb.style.display = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
_showNotePreview() {
|
||||||
|
const title = this.$('#noteEditorTitle')?.value.trim() || '';
|
||||||
|
const content = this._getNoteEditorContent();
|
||||||
|
const folderVal = this.$('#noteEditorFolder')?.value.trim() || '';
|
||||||
|
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
|
||||||
|
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||||
|
|
||||||
|
const titleEl = this.$('#noteReadTitle');
|
||||||
|
if (titleEl) titleEl.textContent = title || 'Untitled';
|
||||||
|
const parts = [];
|
||||||
|
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
|
||||||
|
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||||
|
const metaEl = this.$('#noteReadMeta');
|
||||||
|
if (metaEl) metaEl.innerHTML = parts.join(' ');
|
||||||
|
const readEl = this.$('#noteReadContent');
|
||||||
|
if (readEl) {
|
||||||
|
readEl.innerHTML = formatMessage(content || '');
|
||||||
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(readEl);
|
||||||
|
this._renderWikilinksInReadMode(readEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rm = this.$('#noteReadMode');
|
||||||
|
const em = this.$('#noteEditMode');
|
||||||
|
const eb = this.$('#noteEditBtn');
|
||||||
|
const pb = this.$('#notePreviewBtn');
|
||||||
|
if (rm) rm.style.display = '';
|
||||||
|
if (em) em.style.display = 'none';
|
||||||
|
if (eb) eb.style.display = '';
|
||||||
|
if (pb) pb.style.display = 'none';
|
||||||
|
const delBtn = this.$('#noteDeleteBtn2');
|
||||||
|
if (delBtn) delBtn.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── CRUD ──
|
||||||
|
|
||||||
|
async saveNote() {
|
||||||
|
const title = this.$('#noteEditorTitle')?.value.trim() || '';
|
||||||
|
const content = this._getNoteEditorContent();
|
||||||
|
const folder = this.$('#noteEditorFolder')?.value.trim() || '';
|
||||||
|
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
|
||||||
|
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||||
|
|
||||||
|
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||||
|
if (!content) { UI.toast('Content is required', 'warning'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this._editingNoteId) {
|
||||||
|
const updated = await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
|
||||||
|
this._currentNote = updated;
|
||||||
|
UI.toast('Note updated', 'success');
|
||||||
|
this._showNoteReadMode();
|
||||||
|
} else {
|
||||||
|
const created = await API.createNote(title, content, folder, tags);
|
||||||
|
this._editingNoteId = created.id;
|
||||||
|
this._currentNote = created;
|
||||||
|
UI.toast('Note created', 'success');
|
||||||
|
this._showNoteReadMode();
|
||||||
|
}
|
||||||
|
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteNote() {
|
||||||
|
if (!this._editingNoteId) return;
|
||||||
|
if (!await showConfirm('Delete this note?')) return;
|
||||||
|
try {
|
||||||
|
await API.deleteNote(this._editingNoteId);
|
||||||
|
UI.toast('Note deleted', 'success');
|
||||||
|
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||||
|
this.showNotesList();
|
||||||
|
await this.loadNotesList();
|
||||||
|
await this.loadNoteFolders();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Backlinks ──
|
||||||
|
|
||||||
|
async _loadBacklinks(noteId) {
|
||||||
|
const container = this.$('#noteBacklinks');
|
||||||
|
const listEl = this.$('#noteBacklinksList');
|
||||||
|
const countEl = this.$('#noteBacklinksCount');
|
||||||
|
if (!container || !listEl || !countEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.getNoteBacklinks(noteId);
|
||||||
|
const links = resp.data || [];
|
||||||
|
countEl.textContent = links.length;
|
||||||
|
container.style.display = links.length > 0 ? '' : 'none';
|
||||||
|
listEl.innerHTML = links.map(n => `
|
||||||
|
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
|
||||||
|
<span class="note-backlink-title">${esc(n.title)}</span>
|
||||||
|
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
container.style.display = 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleBacklinks() {
|
||||||
|
const list = this.$('#noteBacklinksList');
|
||||||
|
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Wikilink Read-Mode Rendering ──
|
||||||
|
|
||||||
|
_renderWikilinksInReadMode(containerEl) {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const html = containerEl.innerHTML;
|
||||||
|
let transclusionId = 0;
|
||||||
|
const rendered = html.replace(
|
||||||
|
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
|
||||||
|
(match, bang, title, display) => {
|
||||||
|
const titleTrimmed = title.trim();
|
||||||
|
const displayText = display ? display.trim() : titleTrimmed;
|
||||||
|
if (bang === '!') {
|
||||||
|
const tid = `transclusion-${++transclusionId}`;
|
||||||
|
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
|
||||||
|
<div class="transclusion-header">
|
||||||
|
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="transclusion-content"><span class="text-muted">Loading\u2026</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
containerEl.innerHTML = rendered;
|
||||||
|
this._resolveTransclusions(containerEl);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _resolveTransclusions(containerEl) {
|
||||||
|
const embeds = containerEl.querySelectorAll('.transclusion-embed');
|
||||||
|
if (!embeds.length) return;
|
||||||
|
|
||||||
|
const cache = {};
|
||||||
|
for (const el of embeds) {
|
||||||
|
const title = el.dataset.title;
|
||||||
|
const contentEl = el.querySelector('.transclusion-content');
|
||||||
|
if (!title || !contentEl) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let note = cache[title.toLowerCase()];
|
||||||
|
if (!note) {
|
||||||
|
const resp = await API.searchNoteTitles(title, 1);
|
||||||
|
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
|
||||||
|
if (!match) {
|
||||||
|
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
note = await API.getNote(match.id);
|
||||||
|
cache[title.toLowerCase()] = note;
|
||||||
|
}
|
||||||
|
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
|
||||||
|
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
|
||||||
|
return `[Embedded: ${inner}]`;
|
||||||
|
});
|
||||||
|
contentEl.innerHTML = formatMessage(safeContent);
|
||||||
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
|
||||||
|
} catch {
|
||||||
|
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Daily Notes ──
|
||||||
|
|
||||||
|
async openDailyNote() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const title = `Daily \u2014 ${today}`;
|
||||||
|
const folder = '/daily/';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.searchNoteTitles(title, 1);
|
||||||
|
const existing = (resp.data || []).find(
|
||||||
|
n => n.title.toLowerCase() === title.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await this.openNoteEditor(existing.id);
|
||||||
|
} else {
|
||||||
|
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||||
|
const created = await API.createNote(title, content, folder, ['daily']);
|
||||||
|
await this.openNoteEditor(created.id);
|
||||||
|
}
|
||||||
|
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_isDailyNote(note) {
|
||||||
|
return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateDailyNav(note) {
|
||||||
|
let nav = this.$('#noteDailyNav');
|
||||||
|
if (!this._isDailyNote(note)) {
|
||||||
|
if (nav) nav.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nav) {
|
||||||
|
const toolbar = this.container.querySelector('.notes-editor-toolbar');
|
||||||
|
if (!toolbar) return;
|
||||||
|
nav = document.createElement('div');
|
||||||
|
nav.id = 'noteDailyNav';
|
||||||
|
nav.className = 'note-daily-nav';
|
||||||
|
nav.innerHTML = `
|
||||||
|
<button class="btn-small" id="dailyPrevBtn" title="Previous day">\u2039 Prev</button>
|
||||||
|
<span class="note-daily-date" id="dailyDateLabel"></span>
|
||||||
|
<button class="btn-small" id="dailyNextBtn" title="Next day">Next \u203a</button>
|
||||||
|
`;
|
||||||
|
toolbar.after(nav);
|
||||||
|
nav.querySelector('#dailyPrevBtn')?.addEventListener('click', () => this._navigateDaily(-1));
|
||||||
|
nav.querySelector('#dailyNextBtn')?.addEventListener('click', () => this._navigateDaily(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.style.display = '';
|
||||||
|
const dateStr = note.title.replace('Daily \u2014 ', '');
|
||||||
|
const label = nav.querySelector('#dailyDateLabel');
|
||||||
|
if (label) label.textContent = dateStr;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const nextBtn = nav.querySelector('#dailyNextBtn');
|
||||||
|
if (nextBtn) nextBtn.disabled = dateStr >= today;
|
||||||
|
},
|
||||||
|
|
||||||
|
async _navigateDaily(offset) {
|
||||||
|
if (!this._currentNote || !this._isDailyNote(this._currentNote)) return;
|
||||||
|
const dateStr = this._currentNote.title.replace('Daily \u2014 ', '');
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
d.setDate(d.getDate() + offset);
|
||||||
|
const newDateStr = d.toISOString().slice(0, 10);
|
||||||
|
const title = `Daily \u2014 ${newDateStr}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.searchNoteTitles(title, 1);
|
||||||
|
const existing = (resp.data || []).find(
|
||||||
|
n => n.title.toLowerCase() === title.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await this.openNoteEditor(existing.id);
|
||||||
|
} else {
|
||||||
|
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||||
|
const created = await API.createNote(title, content, '/daily/', ['daily']);
|
||||||
|
await this.openNoteEditor(created.id);
|
||||||
|
}
|
||||||
|
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Save-to-Note (from chat messages) ──
|
||||||
|
|
||||||
|
async saveMessageToNote(msgIndex) {
|
||||||
|
const chat = App.chats?.find(c => c.id === App.activeId);
|
||||||
|
const msg = chat?.messages?.[msgIndex];
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
const sel = window.getSelection();
|
||||||
|
let content = msg.content;
|
||||||
|
|
||||||
|
content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
|
||||||
|
|
||||||
|
const msgId = msg.id || '';
|
||||||
|
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
|
||||||
|
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
|
||||||
|
const selected = sel.toString().trim();
|
||||||
|
if (selected) content = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||||
|
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
|
||||||
|
|
||||||
|
this._showSaveToNoteModal({
|
||||||
|
content,
|
||||||
|
sourceChannelId: App.activeId || '',
|
||||||
|
sourceMessageId: msg.id || '',
|
||||||
|
defaultTitle,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_showSaveToNoteModal(opts) {
|
||||||
|
document.getElementById('saveToNoteModal')?.remove();
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'saveToNoteModal';
|
||||||
|
modal.className = 'modal-overlay active';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal save-to-note-modal">
|
||||||
|
<h3>Save to Note</h3>
|
||||||
|
<div class="save-note-mode-toggle">
|
||||||
|
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
|
||||||
|
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
|
||||||
|
</div>
|
||||||
|
<div id="saveNoteCreateFields">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
|
||||||
|
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="saveNoteAppendFields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
|
||||||
|
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
|
||||||
|
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '\u2026' : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
|
||||||
|
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
let selectedAppendNote = null;
|
||||||
|
|
||||||
|
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', (e) => {
|
||||||
|
const isAppend = e.target.value === 'append';
|
||||||
|
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
|
||||||
|
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
|
||||||
|
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
|
||||||
|
selectedAppendNote = null;
|
||||||
|
document.getElementById('saveNoteSearchResults').innerHTML = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let _searchTimer;
|
||||||
|
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(_searchTimer);
|
||||||
|
const q = e.target.value.trim();
|
||||||
|
selectedAppendNote = null;
|
||||||
|
_searchTimer = setTimeout(async () => {
|
||||||
|
const resultsEl = document.getElementById('saveNoteSearchResults');
|
||||||
|
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
|
||||||
|
try {
|
||||||
|
const resp = await API.searchNoteTitles(q, 8);
|
||||||
|
const notes = resp.data || [];
|
||||||
|
resultsEl.innerHTML = notes.map(n =>
|
||||||
|
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
|
||||||
|
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
|
||||||
|
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
|
||||||
|
document.getElementById('saveNoteSearch').value = el.dataset.title;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
|
||||||
|
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
|
||||||
|
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isAppend) {
|
||||||
|
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
|
||||||
|
await API.updateNote(selectedAppendNote.id, {
|
||||||
|
content: '\n\n---\n\n' + opts.content,
|
||||||
|
mode: 'append',
|
||||||
|
});
|
||||||
|
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
|
||||||
|
} else {
|
||||||
|
const title = document.getElementById('saveNoteTitle').value.trim();
|
||||||
|
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||||
|
const folder = document.getElementById('saveNoteFolder').value.trim();
|
||||||
|
const tagsStr = document.getElementById('saveNoteTags').value.trim();
|
||||||
|
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||||
|
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
|
||||||
|
UI.toast('Note created', 'success');
|
||||||
|
}
|
||||||
|
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||||
|
modal.remove();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveNoteTitle')?.focus();
|
||||||
|
document.getElementById('saveNoteTitle')?.select();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Compound onclick wrappers (for data-action dispatch) ──
|
||||||
|
|
||||||
|
_toggleNoteSelectCb(el, noteId) {
|
||||||
|
this._toggleNoteSelect(noteId, el.checked);
|
||||||
|
},
|
||||||
|
|
||||||
|
_noteItemClick(noteId) {
|
||||||
|
if (this._notesSelectMode) {
|
||||||
|
this._toggleNoteSelect(noteId);
|
||||||
|
} else {
|
||||||
|
this.openNoteEditor(noteId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Lifecycle ──
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._destroyNoteEditor();
|
||||||
|
clearTimeout(this._searchTimer);
|
||||||
|
},
|
||||||
|
}, NotePanel);
|
||||||
|
|
||||||
|
NotePanel._register(id, instance);
|
||||||
|
return instance;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ── Exports ─────────────────────────────────
|
||||||
|
sb.ns('NotePanel', NotePanel);
|
||||||
854
src/js/notes.js
854
src/js/notes.js
@@ -1,754 +1,90 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Chat Switchboard – Notes Panel
|
// Chat Switchboard – Notes Panel (Shim)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Notes panel: editor, multi-select, folders, CRUD.
|
// v0.30.1: Thin backward-compat layer over NotePanel component.
|
||||||
|
// All state and logic now lives in note-panel.js.
|
||||||
|
// This file creates the singleton (NotePanel.primary) and wires
|
||||||
|
// sb.register() calls so data-action attributes continue to work.
|
||||||
|
|
||||||
|
|
||||||
var _editingNoteId = null;
|
// ── Singleton accessor ──
|
||||||
var _notesSelectMode = false;
|
function _np() { return NotePanel.primary; }
|
||||||
var _selectedNoteIds = new Set();
|
|
||||||
var _notesSort = 'updated_desc';
|
|
||||||
var _noteEditor = null; // CM6 noteEditor instance
|
|
||||||
|
|
||||||
// ── Notes ─────────────────────────────────────
|
|
||||||
|
// ── Public API (delegated to NotePanel.primary) ──
|
||||||
|
|
||||||
async function openNotes() {
|
async function openNotes() {
|
||||||
// Toggle: if notes is active, close it; otherwise open it
|
|
||||||
if (PanelRegistry.isOpen('notes')) {
|
if (PanelRegistry.isOpen('notes')) {
|
||||||
PanelRegistry.close('notes');
|
PanelRegistry.close('notes');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PanelRegistry.open('notes');
|
PanelRegistry.open('notes');
|
||||||
_exitSelectMode();
|
_np()?._exitSelectMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNotesList(folder, searchQuery) {
|
async function loadNotesList(folder, searchQuery) {
|
||||||
const list = document.getElementById('notesList');
|
return _np()?.loadNotesList(folder, searchQuery);
|
||||||
list.innerHTML = '<div class="loading">Loading…</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
let data;
|
|
||||||
if (searchQuery) {
|
|
||||||
data = await API.searchNotes(searchQuery);
|
|
||||||
const results = data.data || [];
|
|
||||||
if (results.length === 0) {
|
|
||||||
list.innerHTML = '<div class="empty-hint">No results found</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
|
|
||||||
} else {
|
|
||||||
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
|
|
||||||
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
|
|
||||||
const notes = data.data || [];
|
|
||||||
if (notes.length === 0) {
|
|
||||||
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _noteListItem(note, isSearch) {
|
|
||||||
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
|
|
||||||
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
|
|
||||||
const preview = isSearch && note.headline
|
|
||||||
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
|
|
||||||
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
|
|
||||||
const time = _relativeTime(note.updated_at);
|
|
||||||
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
|
|
||||||
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
|
|
||||||
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
|
|
||||||
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
|
|
||||||
</div>
|
|
||||||
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
|
|
||||||
<div class="note-item-header">
|
|
||||||
<span class="note-item-title">${esc(note.title)}</span>
|
|
||||||
<span class="note-item-time">${time}</span>
|
|
||||||
</div>
|
|
||||||
${preview}
|
|
||||||
<div class="note-item-meta">${folder}${tags}</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _highlightHeadline(headline) {
|
|
||||||
const escaped = esc(headline);
|
|
||||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Multi-select ────────────────
|
|
||||||
|
|
||||||
function _enterSelectMode() {
|
|
||||||
_notesSelectMode = true;
|
|
||||||
_selectedNoteIds.clear();
|
|
||||||
document.getElementById('notesSelectionBar').style.display = '';
|
|
||||||
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
|
|
||||||
_updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _exitSelectMode() {
|
|
||||||
_notesSelectMode = false;
|
|
||||||
_selectedNoteIds.clear();
|
|
||||||
document.getElementById('notesSelectionBar').style.display = 'none';
|
|
||||||
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
|
|
||||||
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
|
|
||||||
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
|
|
||||||
const sa = document.getElementById('notesSelectAll');
|
|
||||||
if (sa) sa.checked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _toggleNoteSelect(id, forceState) {
|
|
||||||
if (!_notesSelectMode) {
|
|
||||||
_enterSelectMode();
|
|
||||||
}
|
|
||||||
const has = _selectedNoteIds.has(id);
|
|
||||||
const newState = forceState !== undefined ? forceState : !has;
|
|
||||||
|
|
||||||
if (newState) _selectedNoteIds.add(id);
|
|
||||||
else _selectedNoteIds.delete(id);
|
|
||||||
|
|
||||||
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
|
|
||||||
if (item) {
|
|
||||||
item.classList.toggle('selected', newState);
|
|
||||||
const cb = item.querySelector('.note-checkbox');
|
|
||||||
if (cb) cb.checked = newState;
|
|
||||||
}
|
|
||||||
_updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _toggleSelectAll(checked) {
|
|
||||||
document.querySelectorAll('.note-checkbox').forEach(cb => {
|
|
||||||
const id = cb.dataset.id;
|
|
||||||
if (checked) _selectedNoteIds.add(id);
|
|
||||||
else _selectedNoteIds.delete(id);
|
|
||||||
cb.checked = checked;
|
|
||||||
cb.closest('.note-item')?.classList.toggle('selected', checked);
|
|
||||||
});
|
|
||||||
_updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _updateSelectedCount() {
|
|
||||||
const el = document.getElementById('notesSelectedCount');
|
|
||||||
if (el) el.textContent = _selectedNoteIds.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _bulkDeleteSelected() {
|
|
||||||
if (_selectedNoteIds.size === 0) return;
|
|
||||||
const count = _selectedNoteIds.size;
|
|
||||||
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
|
|
||||||
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
|
|
||||||
_exitSelectMode();
|
|
||||||
await loadNotesList();
|
|
||||||
await loadNoteFolders();
|
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Folders / Editor / CRUD ─────
|
|
||||||
|
|
||||||
async function loadNoteFolders() {
|
|
||||||
const sel = document.getElementById('notesFolderFilter');
|
|
||||||
if (!sel) return;
|
|
||||||
try {
|
|
||||||
const data = await API.listNoteFolders();
|
|
||||||
const folders = data.folders || [];
|
|
||||||
sel.innerHTML = '<option value="">All folders</option>';
|
|
||||||
folders.forEach(f => {
|
|
||||||
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
|
|
||||||
});
|
|
||||||
} catch (e) { /* non-critical */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNotesList() {
|
function showNotesList() {
|
||||||
document.getElementById('notesListView').style.display = '';
|
return _np()?.showNotesList();
|
||||||
document.getElementById('notesEditorView').style.display = 'none';
|
|
||||||
const graphView = document.getElementById('notesGraphView');
|
|
||||||
if (graphView) graphView.style.display = 'none';
|
|
||||||
_editingNoteId = null;
|
|
||||||
_destroyNoteEditor();
|
|
||||||
// Reset list to loading state to prevent stale content flash
|
|
||||||
const list = document.getElementById('notesList');
|
|
||||||
if (list) list.innerHTML = '<div class="loading">Loading…</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _currentNote = null; // cached note for read mode
|
async function loadNoteFolders() {
|
||||||
|
return _np()?.loadNoteFolders();
|
||||||
function copyNoteContent() {
|
|
||||||
if (!_currentNote) return;
|
|
||||||
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
|
|
||||||
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openNoteEditor(noteId) {
|
async function openNoteEditor(noteId) {
|
||||||
document.getElementById('notesListView').style.display = 'none';
|
return _np()?.openNoteEditor(noteId);
|
||||||
document.getElementById('notesEditorView').style.display = '';
|
|
||||||
|
|
||||||
if (noteId) {
|
|
||||||
_editingNoteId = noteId;
|
|
||||||
try {
|
|
||||||
_currentNote = await API.getNote(noteId);
|
|
||||||
_populateEditFields(_currentNote);
|
|
||||||
_showNoteReadMode();
|
|
||||||
} catch (e) {
|
|
||||||
UI.toast('Failed to load note: ' + e.message, 'error');
|
|
||||||
showNotesList();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New note — straight to edit mode
|
|
||||||
_editingNoteId = null;
|
|
||||||
_currentNote = null;
|
|
||||||
_clearEditFields();
|
|
||||||
_showNoteEditMode();
|
|
||||||
document.getElementById('noteEditorTitle').focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _populateEditFields(note) {
|
|
||||||
document.getElementById('noteEditorTitle').value = note.title || '';
|
|
||||||
document.getElementById('noteEditorFolder').value = note.folder_path || '';
|
|
||||||
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
|
|
||||||
_setNoteEditorContent(note.content || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _clearEditFields() {
|
|
||||||
document.getElementById('noteEditorTitle').value = '';
|
|
||||||
document.getElementById('noteEditorFolder').value = '';
|
|
||||||
document.getElementById('noteEditorTags').value = '';
|
|
||||||
_setNoteEditorContent('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get content from CM6 editor or fallback textarea */
|
|
||||||
function _getNoteEditorContent() {
|
|
||||||
if (_noteEditor) return _noteEditor.getValue();
|
|
||||||
const ta = document.querySelector('#noteEditorContentContainer textarea');
|
|
||||||
return ta ? ta.value : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set content in CM6 editor or fallback textarea */
|
|
||||||
function _setNoteEditorContent(text) {
|
|
||||||
if (_noteEditor) {
|
|
||||||
_noteEditor.setValue(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Lazy-init CM6 note editor
|
|
||||||
const container = document.getElementById('noteEditorContentContainer');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (window.CM?.noteEditor) {
|
|
||||||
container.innerHTML = ''; // clear any previous
|
|
||||||
_noteEditor = CM.noteEditor(container, {
|
|
||||||
value: text,
|
|
||||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
|
||||||
onChange: null, // could wire auto-save later
|
|
||||||
onLink: (title) => {
|
|
||||||
_navigateToLinkedNote(title);
|
|
||||||
},
|
|
||||||
linkCompleter: async (query) => {
|
|
||||||
if (!query || query.length < 1) return [];
|
|
||||||
try {
|
|
||||||
const resp = await API.searchNoteTitles(query, 8);
|
|
||||||
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
|
|
||||||
} catch { return []; }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback: plain textarea
|
|
||||||
if (!container.querySelector('textarea')) {
|
|
||||||
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown..."></textarea>';
|
|
||||||
}
|
|
||||||
const ta = container.querySelector('textarea');
|
|
||||||
if (ta) ta.value = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigate to a note by title (from wikilink click) */
|
|
||||||
async function _navigateToLinkedNote(title) {
|
|
||||||
try {
|
|
||||||
const resp = await API.searchNoteTitles(title, 1);
|
|
||||||
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
|
|
||||||
if (match) {
|
|
||||||
await openNoteEditor(match.id);
|
|
||||||
} else {
|
|
||||||
// Offer to create
|
|
||||||
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
|
|
||||||
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
|
|
||||||
await openNoteEditor(created.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Destroy CM6 editor on panel close */
|
|
||||||
function _destroyNoteEditor() {
|
|
||||||
if (_noteEditor) {
|
|
||||||
_noteEditor.destroy();
|
|
||||||
_noteEditor = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showNoteReadMode() {
|
|
||||||
if (!_currentNote) return;
|
|
||||||
const n = _currentNote;
|
|
||||||
|
|
||||||
// Title
|
|
||||||
document.getElementById('noteReadTitle').textContent = n.title || '';
|
|
||||||
|
|
||||||
// Meta: folder + tags
|
|
||||||
const parts = [];
|
|
||||||
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
|
|
||||||
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
|
||||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
|
||||||
|
|
||||||
// Rendered markdown content — reuse the chat formatter
|
|
||||||
const noteReadEl = document.getElementById('noteReadContent');
|
|
||||||
noteReadEl.innerHTML = formatMessage(n.content || '');
|
|
||||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
|
|
||||||
|
|
||||||
// Render [[wikilinks]] as clickable chips in read mode
|
|
||||||
_renderWikilinksInReadMode(noteReadEl);
|
|
||||||
|
|
||||||
// Show/hide delete
|
|
||||||
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
|
||||||
|
|
||||||
// Toggle visibility
|
|
||||||
document.getElementById('noteReadMode').style.display = '';
|
|
||||||
document.getElementById('noteEditMode').style.display = 'none';
|
|
||||||
document.getElementById('noteEditBtn').style.display = '';
|
|
||||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
|
||||||
|
|
||||||
// Load backlinks
|
|
||||||
if (_editingNoteId) _loadBacklinks(_editingNoteId);
|
|
||||||
|
|
||||||
// Show daily note navigation if applicable
|
|
||||||
_updateDailyNav(_currentNote);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showNoteEditMode() {
|
|
||||||
document.getElementById('noteReadMode').style.display = 'none';
|
|
||||||
document.getElementById('noteEditMode').style.display = '';
|
|
||||||
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
|
|
||||||
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
|
|
||||||
document.getElementById('noteEditBtn').style.display = 'none';
|
|
||||||
document.getElementById('notePreviewBtn').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showNotePreview() {
|
|
||||||
// Live preview from editor without saving
|
|
||||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
|
||||||
const content = _getNoteEditorContent();
|
|
||||||
const folderVal = document.getElementById('noteEditorFolder').value.trim();
|
|
||||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
|
||||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
||||||
|
|
||||||
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
|
|
||||||
const parts = [];
|
|
||||||
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
|
|
||||||
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
|
||||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
|
||||||
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
|
||||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
|
|
||||||
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
|
|
||||||
|
|
||||||
document.getElementById('noteReadMode').style.display = '';
|
|
||||||
document.getElementById('noteEditMode').style.display = 'none';
|
|
||||||
document.getElementById('noteEditBtn').style.display = '';
|
|
||||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
|
||||||
// Keep delete hidden in preview-from-edit
|
|
||||||
document.getElementById('noteDeleteBtn2').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveNote() {
|
async function saveNote() {
|
||||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
return _np()?.saveNote();
|
||||||
const content = _getNoteEditorContent();
|
|
||||||
const folder = document.getElementById('noteEditorFolder').value.trim();
|
|
||||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
|
||||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
||||||
|
|
||||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
|
||||||
if (!content) { UI.toast('Content is required', 'warning'); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (_editingNoteId) {
|
|
||||||
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
|
|
||||||
_currentNote = updated;
|
|
||||||
UI.toast('Note updated', 'success');
|
|
||||||
_showNoteReadMode();
|
|
||||||
} else {
|
|
||||||
const created = await API.createNote(title, content, folder, tags);
|
|
||||||
_editingNoteId = created.id;
|
|
||||||
_currentNote = created;
|
|
||||||
UI.toast('Note created', 'success');
|
|
||||||
_showNoteReadMode();
|
|
||||||
}
|
|
||||||
// Invalidate graph cache
|
|
||||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNote() {
|
async function deleteNote() {
|
||||||
if (!_editingNoteId) return;
|
return _np()?.deleteNote();
|
||||||
if (!await showConfirm('Delete this note?')) return;
|
|
||||||
try {
|
|
||||||
await API.deleteNote(_editingNoteId);
|
|
||||||
UI.toast('Note deleted', 'success');
|
|
||||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
|
||||||
showNotesList();
|
|
||||||
await loadNotesList();
|
|
||||||
await loadNoteFolders();
|
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backlinks ───────────────────
|
async function openDailyNote() {
|
||||||
|
return _np()?.openDailyNote();
|
||||||
async function _loadBacklinks(noteId) {
|
|
||||||
const container = document.getElementById('noteBacklinks');
|
|
||||||
const listEl = document.getElementById('noteBacklinksList');
|
|
||||||
const countEl = document.getElementById('noteBacklinksCount');
|
|
||||||
if (!container || !listEl || !countEl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await API.getNoteBacklinks(noteId);
|
|
||||||
const links = resp.data || [];
|
|
||||||
countEl.textContent = links.length;
|
|
||||||
container.style.display = links.length > 0 ? '' : 'none';
|
|
||||||
listEl.innerHTML = links.map(n => `
|
|
||||||
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
|
|
||||||
<span class="note-backlink-title">${esc(n.title)}</span>
|
|
||||||
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch {
|
|
||||||
container.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMessageToNote(msgIndex) {
|
||||||
|
return _np()?.saveMessageToNote(msgIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleNoteSelect(id, forceState) {
|
||||||
|
return _np()?._toggleNoteSelect(id, forceState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _navigateToLinkedNote(title) {
|
||||||
|
return _np()?._navigateToLinkedNote(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleBacklinks() {
|
function toggleBacklinks() {
|
||||||
const list = document.getElementById('noteBacklinksList');
|
return _np()?.toggleBacklinks();
|
||||||
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Wikilink Read-Mode Rendering ──
|
function _toggleNoteSelectCb(el, noteId) {
|
||||||
|
return _np()?._toggleNoteSelectCb(el, noteId);
|
||||||
function _renderWikilinksInReadMode(containerEl) {
|
|
||||||
if (!containerEl) return;
|
|
||||||
const html = containerEl.innerHTML;
|
|
||||||
let transclusionId = 0;
|
|
||||||
// Replace [[Title|display]] and [[Title]] with clickable spans
|
|
||||||
// For ![[transclusion]], render a placeholder that gets filled async
|
|
||||||
const rendered = html.replace(
|
|
||||||
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
|
|
||||||
(match, bang, title, display) => {
|
|
||||||
const titleTrimmed = title.trim();
|
|
||||||
const displayText = display ? display.trim() : titleTrimmed;
|
|
||||||
if (bang === '!') {
|
|
||||||
const tid = `transclusion-${++transclusionId}`;
|
|
||||||
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
|
|
||||||
<div class="transclusion-header">
|
|
||||||
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="transclusion-content"><span class="text-muted">Loading…</span></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
containerEl.innerHTML = rendered;
|
|
||||||
|
|
||||||
// Async: resolve and embed transclusion content
|
|
||||||
_resolveTransclusions(containerEl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
|
function _noteItemClick(noteId) {
|
||||||
async function _resolveTransclusions(containerEl) {
|
return _np()?._noteItemClick(noteId);
|
||||||
const embeds = containerEl.querySelectorAll('.transclusion-embed');
|
|
||||||
if (!embeds.length) return;
|
|
||||||
|
|
||||||
// Cache to avoid duplicate fetches in same note
|
|
||||||
const cache = {};
|
|
||||||
|
|
||||||
for (const el of embeds) {
|
|
||||||
const title = el.dataset.title;
|
|
||||||
const contentEl = el.querySelector('.transclusion-content');
|
|
||||||
if (!title || !contentEl) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let note = cache[title.toLowerCase()];
|
|
||||||
if (!note) {
|
|
||||||
const resp = await API.searchNoteTitles(title, 1);
|
|
||||||
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
|
|
||||||
if (!match) {
|
|
||||||
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
note = await API.getNote(match.id);
|
|
||||||
cache[title.toLowerCase()] = note;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render embedded content (no recursive transclusion — strip ![[]] )
|
// Read-only accessors for backward compat (sb.register registered these as values)
|
||||||
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
|
function _currentNote() { return _np()?._currentNote; }
|
||||||
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
|
function _editingNoteId() { return _np()?._editingNoteId; }
|
||||||
return `[Embedded: ${inner}]`;
|
|
||||||
});
|
|
||||||
contentEl.innerHTML = formatMessage(safeContent);
|
|
||||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
|
|
||||||
} catch {
|
|
||||||
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Daily Notes ─────────────────
|
|
||||||
|
|
||||||
async function openDailyNote() {
|
// ── Panel Registration ──
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
// Creates the singleton NotePanel.primary and registers with PanelRegistry.
|
||||||
const title = `Daily — ${today}`;
|
|
||||||
const folder = '/daily/';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await API.searchNoteTitles(title, 1);
|
|
||||||
const existing = (resp.data || []).find(
|
|
||||||
n => n.title.toLowerCase() === title.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await openNoteEditor(existing.id);
|
|
||||||
} else {
|
|
||||||
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
|
||||||
const created = await API.createNote(title, content, folder, ['daily']);
|
|
||||||
await openNoteEditor(created.id);
|
|
||||||
}
|
|
||||||
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Detect if current note is a daily note */
|
|
||||||
function _isDailyNote(note) {
|
|
||||||
return note && /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show prev/next nav for daily notes */
|
|
||||||
function _updateDailyNav(note) {
|
|
||||||
let nav = document.getElementById('noteDailyNav');
|
|
||||||
if (!_isDailyNote(note)) {
|
|
||||||
if (nav) nav.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure nav element exists
|
|
||||||
if (!nav) {
|
|
||||||
const toolbar = document.querySelector('.notes-editor-toolbar');
|
|
||||||
if (!toolbar) return;
|
|
||||||
nav = document.createElement('div');
|
|
||||||
nav.id = 'noteDailyNav';
|
|
||||||
nav.className = 'note-daily-nav';
|
|
||||||
nav.innerHTML = `
|
|
||||||
<button class="btn-small" id="dailyPrevBtn" title="Previous day">‹ Prev</button>
|
|
||||||
<span class="note-daily-date" id="dailyDateLabel"></span>
|
|
||||||
<button class="btn-small" id="dailyNextBtn" title="Next day">Next ›</button>
|
|
||||||
`;
|
|
||||||
toolbar.after(nav);
|
|
||||||
document.getElementById('dailyPrevBtn')?.addEventListener('click', () => _navigateDaily(-1));
|
|
||||||
document.getElementById('dailyNextBtn')?.addEventListener('click', () => _navigateDaily(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.style.display = '';
|
|
||||||
const dateStr = note.title.replace('Daily — ', '');
|
|
||||||
document.getElementById('dailyDateLabel').textContent = dateStr;
|
|
||||||
|
|
||||||
// Disable "Next" if it's today or future
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
document.getElementById('dailyNextBtn').disabled = dateStr >= today;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigate to prev/next daily note */
|
|
||||||
async function _navigateDaily(offset) {
|
|
||||||
if (!_currentNote || !_isDailyNote(_currentNote)) return;
|
|
||||||
const dateStr = _currentNote.title.replace('Daily — ', '');
|
|
||||||
const d = new Date(dateStr + 'T12:00:00'); // noon to avoid DST edge
|
|
||||||
d.setDate(d.getDate() + offset);
|
|
||||||
const newDateStr = d.toISOString().slice(0, 10);
|
|
||||||
const title = `Daily — ${newDateStr}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await API.searchNoteTitles(title, 1);
|
|
||||||
const existing = (resp.data || []).find(
|
|
||||||
n => n.title.toLowerCase() === title.toLowerCase()
|
|
||||||
);
|
|
||||||
if (existing) {
|
|
||||||
await openNoteEditor(existing.id);
|
|
||||||
} else {
|
|
||||||
// Create the daily note for that day
|
|
||||||
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
|
||||||
const created = await API.createNote(title, content, '/daily/', ['daily']);
|
|
||||||
await openNoteEditor(created.id);
|
|
||||||
}
|
|
||||||
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Save-to-Note (from chat messages) ──
|
|
||||||
|
|
||||||
async function saveMessageToNote(msgIndex) {
|
|
||||||
const chat = App.chats?.find(c => c.id === App.activeId);
|
|
||||||
const msg = chat?.messages?.[msgIndex];
|
|
||||||
if (!msg) return;
|
|
||||||
|
|
||||||
// Check for text selection within the message element
|
|
||||||
const sel = window.getSelection();
|
|
||||||
let content = msg.content;
|
|
||||||
|
|
||||||
// Strip thinking/reasoning blocks — only the response matters in notes
|
|
||||||
content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
|
|
||||||
|
|
||||||
const msgId = msg.id || '';
|
|
||||||
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
|
|
||||||
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
|
|
||||||
const selected = sel.toString().trim();
|
|
||||||
if (selected) content = selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract title from first line
|
|
||||||
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
|
||||||
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
|
|
||||||
|
|
||||||
// Show save-to-note modal
|
|
||||||
_showSaveToNoteModal({
|
|
||||||
content,
|
|
||||||
sourceChannelId: App.activeId || '',
|
|
||||||
sourceMessageId: msg.id || '',
|
|
||||||
defaultTitle,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Modal for save-to-note with create/append options */
|
|
||||||
function _showSaveToNoteModal(opts) {
|
|
||||||
// Remove existing modal if any
|
|
||||||
document.getElementById('saveToNoteModal')?.remove();
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.id = 'saveToNoteModal';
|
|
||||||
modal.className = 'modal-overlay active';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal save-to-note-modal">
|
|
||||||
<h3>Save to Note</h3>
|
|
||||||
<div class="save-note-mode-toggle">
|
|
||||||
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
|
|
||||||
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
|
|
||||||
</div>
|
|
||||||
<div id="saveNoteCreateFields">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
|
|
||||||
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="saveNoteAppendFields" style="display:none">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
|
|
||||||
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
|
|
||||||
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '…' : ''}</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
|
|
||||||
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
let selectedAppendNote = null;
|
|
||||||
|
|
||||||
// Toggle create vs append
|
|
||||||
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
|
|
||||||
radio.addEventListener('change', (e) => {
|
|
||||||
const isAppend = e.target.value === 'append';
|
|
||||||
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
|
|
||||||
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
|
|
||||||
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
|
|
||||||
selectedAppendNote = null;
|
|
||||||
document.getElementById('saveNoteSearchResults').innerHTML = '';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Append search with debounce
|
|
||||||
let _searchTimer;
|
|
||||||
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
|
|
||||||
clearTimeout(_searchTimer);
|
|
||||||
const q = e.target.value.trim();
|
|
||||||
selectedAppendNote = null;
|
|
||||||
_searchTimer = setTimeout(async () => {
|
|
||||||
const resultsEl = document.getElementById('saveNoteSearchResults');
|
|
||||||
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
|
|
||||||
try {
|
|
||||||
const resp = await API.searchNoteTitles(q, 8);
|
|
||||||
const notes = resp.data || [];
|
|
||||||
resultsEl.innerHTML = notes.map(n =>
|
|
||||||
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
|
|
||||||
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
|
|
||||||
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
|
|
||||||
el.classList.add('selected');
|
|
||||||
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
|
|
||||||
document.getElementById('saveNoteSearch').value = el.dataset.title;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
|
|
||||||
}, 250);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cancel
|
|
||||||
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
|
|
||||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
|
||||||
|
|
||||||
// Confirm
|
|
||||||
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
|
|
||||||
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isAppend) {
|
|
||||||
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
|
|
||||||
await API.updateNote(selectedAppendNote.id, {
|
|
||||||
content: '\n\n---\n\n' + opts.content,
|
|
||||||
mode: 'append',
|
|
||||||
});
|
|
||||||
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
|
|
||||||
} else {
|
|
||||||
const title = document.getElementById('saveNoteTitle').value.trim();
|
|
||||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
|
||||||
const folder = document.getElementById('saveNoteFolder').value.trim();
|
|
||||||
const tagsStr = document.getElementById('saveNoteTags').value.trim();
|
|
||||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
||||||
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
|
|
||||||
UI.toast('Note created', 'success');
|
|
||||||
}
|
|
||||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
|
||||||
modal.remove();
|
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focus title
|
|
||||||
document.getElementById('saveNoteTitle')?.focus();
|
|
||||||
document.getElementById('saveNoteTitle')?.select();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notes Listeners (extracted from initListeners) ──
|
|
||||||
|
|
||||||
function _registerNotesPanel() {
|
function _registerNotesPanel() {
|
||||||
const body = document.getElementById('sidePanelBody');
|
const body = document.getElementById('sidePanelBody');
|
||||||
if (!body || typeof PanelRegistry === 'undefined') return;
|
if (!body || typeof PanelRegistry === 'undefined' || typeof NotePanel === 'undefined') return;
|
||||||
|
|
||||||
// ── Create the panel element dynamically ──
|
// ── Create the panel element dynamically ──
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
@@ -760,12 +96,12 @@ function _registerNotesPanel() {
|
|||||||
<div id="notesListView">
|
<div id="notesListView">
|
||||||
<div class="notes-toolbar">
|
<div class="notes-toolbar">
|
||||||
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
|
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
|
||||||
<button id="notesTodayBtn" class="btn-small" title="Open daily note">📅 Today</button>
|
<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>
|
||||||
<button id="notesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
|
<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>
|
||||||
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">☑ Select</button>
|
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="notes-search-row">
|
<div class="notes-search-row">
|
||||||
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes…" autocomplete="off">
|
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="notes-filter-row">
|
<div class="notes-filter-row">
|
||||||
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
|
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
|
||||||
@@ -786,7 +122,7 @@ function _registerNotesPanel() {
|
|||||||
<!-- Editor view -->
|
<!-- Editor view -->
|
||||||
<div id="notesEditorView" style="display:none;">
|
<div id="notesEditorView" style="display:none;">
|
||||||
<div class="notes-editor-header">
|
<div class="notes-editor-header">
|
||||||
<button id="notesBackBtn" class="btn-small" title="Back to list">← Back</button>
|
<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
|
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
|
||||||
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
|
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
|
||||||
@@ -802,7 +138,7 @@ function _registerNotesPanel() {
|
|||||||
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
|
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
|
||||||
</div>
|
</div>
|
||||||
<div id="noteEditorContentContainer" class="notes-content-container">
|
<div id="noteEditorContentContainer" class="notes-content-container">
|
||||||
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown…"></textarea>
|
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Read mode -->
|
<!-- Read mode -->
|
||||||
@@ -825,9 +161,9 @@ function _registerNotesPanel() {
|
|||||||
<!-- Graph view -->
|
<!-- Graph view -->
|
||||||
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
|
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
|
||||||
<div class="notes-graph-toolbar">
|
<div class="notes-graph-toolbar">
|
||||||
<button class="btn-small" id="notesGraphBackBtn" title="Back to list">← Back</button>
|
<button class="btn-small" id="notesGraphBackBtn" title="Back to list">\u2190 Back</button>
|
||||||
<span class="notes-graph-stats">
|
<span class="notes-graph-stats">
|
||||||
<span id="graphNodeCount">0</span> notes · <span id="graphEdgeCount">0</span> links
|
<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links
|
||||||
</span>
|
</span>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
|
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
|
||||||
@@ -837,67 +173,65 @@ function _registerNotesPanel() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
body.appendChild(el);
|
body.appendChild(el);
|
||||||
|
|
||||||
|
// ── Create NotePanel singleton ──
|
||||||
|
const np = NotePanel.create({ container: el });
|
||||||
|
NotePanel.primary = np;
|
||||||
|
|
||||||
// ── Wire listeners ──
|
// ── Wire listeners ──
|
||||||
// Sidebar trigger (lives outside this panel)
|
|
||||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||||
|
|
||||||
// Toolbar buttons
|
el.querySelector('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
|
||||||
el.querySelector('#notesNewBtn').addEventListener('click', () => openNoteEditor(null));
|
|
||||||
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
|
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
|
||||||
if (typeof openNoteGraph === 'function') openNoteGraph();
|
if (typeof openNoteGraph === 'function') openNoteGraph();
|
||||||
});
|
});
|
||||||
el.querySelector('#notesTodayBtn').addEventListener('click', openDailyNote);
|
el.querySelector('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
|
||||||
el.querySelector('#notesGraphBackBtn').addEventListener('click', () => {
|
el.querySelector('#notesGraphBackBtn').addEventListener('click', () => {
|
||||||
if (typeof closeNoteGraph === 'function') closeNoteGraph();
|
if (typeof closeNoteGraph === 'function') closeNoteGraph();
|
||||||
});
|
});
|
||||||
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
|
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
|
||||||
if (_notesSelectMode) _exitSelectMode();
|
if (np._notesSelectMode) np._exitSelectMode();
|
||||||
else _enterSelectMode();
|
else np._enterSelectMode();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editor header buttons
|
el.querySelector('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
|
||||||
el.querySelector('#notesBackBtn').addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
|
el.querySelector('#noteSaveBtn').addEventListener('click', () => np.saveNote());
|
||||||
el.querySelector('#noteSaveBtn').addEventListener('click', saveNote);
|
el.querySelector('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
|
||||||
el.querySelector('#noteDeleteBtn').addEventListener('click', deleteNote);
|
el.querySelector('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
|
||||||
el.querySelector('#noteDeleteBtn2').addEventListener('click', deleteNote);
|
el.querySelector('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
|
||||||
el.querySelector('#noteEditBtn').addEventListener('click', _showNoteEditMode);
|
el.querySelector('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
|
||||||
el.querySelector('#notePreviewBtn').addEventListener('click', _showNotePreview);
|
|
||||||
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
|
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
|
||||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
|
||||||
else showNotesList();
|
else np.showNotesList();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filters
|
|
||||||
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
|
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
|
||||||
document.getElementById('notesSearchInput').value = '';
|
np.$('#notesSearchInput').value = '';
|
||||||
_exitSelectMode();
|
np._exitSelectMode();
|
||||||
loadNotesList(e.target.value);
|
np.loadNotesList(e.target.value);
|
||||||
});
|
});
|
||||||
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
|
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
|
||||||
_notesSort = e.target.value;
|
np._notesSort = e.target.value;
|
||||||
_exitSelectMode();
|
np._exitSelectMode();
|
||||||
loadNotesList();
|
np.loadNotesList();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multi-select controls
|
|
||||||
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
|
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
|
||||||
_toggleSelectAll(e.target.checked);
|
np._toggleSelectAll(e.target.checked);
|
||||||
});
|
});
|
||||||
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', _bulkDeleteSelected);
|
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
|
||||||
el.querySelector('#notesCancelSelectBtn').addEventListener('click', _exitSelectMode);
|
el.querySelector('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
|
||||||
|
|
||||||
// Search with debounce
|
|
||||||
let _notesSearchTimer;
|
let _notesSearchTimer;
|
||||||
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
|
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
|
||||||
clearTimeout(_notesSearchTimer);
|
clearTimeout(_notesSearchTimer);
|
||||||
const q = e.target.value.trim();
|
const q = e.target.value.trim();
|
||||||
_notesSearchTimer = setTimeout(() => {
|
_notesSearchTimer = setTimeout(() => {
|
||||||
_exitSelectMode();
|
np._exitSelectMode();
|
||||||
if (q.length >= 2) {
|
if (q.length >= 2) {
|
||||||
document.getElementById('notesFolderFilter').value = '';
|
np.$('#notesFolderFilter').value = '';
|
||||||
loadNotesList(null, q);
|
np.loadNotesList(null, q);
|
||||||
} else if (q.length === 0) {
|
} else if (q.length === 0) {
|
||||||
loadNotesList();
|
np.loadNotesList();
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
@@ -907,29 +241,28 @@ function _registerNotesPanel() {
|
|||||||
element: el,
|
element: el,
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
onOpen() {
|
onOpen() {
|
||||||
// Ensure notes are loaded whenever the panel opens
|
np.loadNotesList();
|
||||||
loadNotesList();
|
np.loadNoteFolders();
|
||||||
loadNoteFolders();
|
|
||||||
},
|
},
|
||||||
onClose() {
|
onClose() {
|
||||||
_destroyNoteEditor();
|
np._destroyNoteEditor();
|
||||||
},
|
},
|
||||||
saveState() {
|
saveState() {
|
||||||
const listView = document.getElementById('notesListView');
|
const listView = np.$('#notesListView');
|
||||||
const editorView = document.getElementById('notesEditorView');
|
const editorView = np.$('#notesEditorView');
|
||||||
const graphView = document.getElementById('notesGraphView');
|
const graphView = np.$('#notesGraphView');
|
||||||
let viewMode = 'list';
|
let viewMode = 'list';
|
||||||
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
|
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
|
||||||
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
|
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
|
||||||
return {
|
return {
|
||||||
scrollTop: listView?.scrollTop || 0,
|
scrollTop: listView?.scrollTop || 0,
|
||||||
editingNoteId: _editingNoteId,
|
editingNoteId: np._editingNoteId,
|
||||||
viewMode,
|
viewMode,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
restoreState(state) {
|
restoreState(state) {
|
||||||
if (state.scrollTop) {
|
if (state.scrollTop) {
|
||||||
const listView = document.getElementById('notesListView');
|
const listView = np.$('#notesListView');
|
||||||
if (listView) listView.scrollTop = state.scrollTop;
|
if (listView) listView.scrollTop = state.scrollTop;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -939,21 +272,6 @@ function _registerNotesPanel() {
|
|||||||
/** @deprecated — listeners are now wired in _registerNotesPanel() */
|
/** @deprecated — listeners are now wired in _registerNotesPanel() */
|
||||||
function _initNotesListeners() {
|
function _initNotesListeners() {
|
||||||
// No-op: kept for backward compatibility with app.js calling sequence.
|
// No-op: kept for backward compatibility with app.js calling sequence.
|
||||||
// All wiring moved into _registerNotesPanel().
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wrappers for compound onclick expressions ──
|
|
||||||
// Checkbox toggle: reads .checked from the clicked element
|
|
||||||
function _toggleNoteSelectCb(el, noteId) {
|
|
||||||
_toggleNoteSelect(noteId, el.checked);
|
|
||||||
}
|
|
||||||
// Note list item click: mode-dependent dispatch
|
|
||||||
function _noteItemClick(noteId) {
|
|
||||||
if (_notesSelectMode) {
|
|
||||||
_toggleNoteSelect(noteId);
|
|
||||||
} else {
|
|
||||||
openNoteEditor(noteId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Exports ─────────────────────────────────
|
// ── Exports ─────────────────────────────────
|
||||||
|
|||||||
@@ -25,8 +25,6 @@
|
|||||||
//
|
//
|
||||||
// Exports: window.sb
|
// Exports: window.sb
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const sb = {
|
const sb = {
|
||||||
_actions: new Map(),
|
_actions: new Map(),
|
||||||
_namespaces: new Map(),
|
_namespaces: new Map(),
|
||||||
|
|||||||
@@ -255,16 +255,45 @@ const Switchboard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize notes in a container element.
|
* Create a NotePanel instance in a container element.
|
||||||
* NOTE: v0.28.5 stub — notes lacks a clean create() factory.
|
* Wraps NotePanel.create() with SDK-aware defaults.
|
||||||
* Full component extraction is pre-1.0 tech debt.
|
|
||||||
*
|
*
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container — mount target
|
||||||
* @param {object} opts — { projectId }
|
* @param {object} opts — { projectId }
|
||||||
|
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
|
||||||
*/
|
*/
|
||||||
sw.notes = function (container, opts) {
|
sw.notes = function (container, opts) {
|
||||||
console.warn('[Switchboard] sw.notes() not yet available — notes component needs refactor (pre-1.0)');
|
if (typeof NotePanel === 'undefined') {
|
||||||
|
console.error('[Switchboard] NotePanel not available');
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
const _opts = opts || {};
|
||||||
|
return NotePanel.create({
|
||||||
|
container,
|
||||||
|
projectId: _opts.projectId || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access the PanelRegistry through the SDK.
|
||||||
|
* Returns a thin wrapper with open/close/toggle/isOpen/active/cycle/register.
|
||||||
|
*
|
||||||
|
* @returns {object|null} Panel control object
|
||||||
|
*/
|
||||||
|
sw.panels = function () {
|
||||||
|
if (typeof PanelRegistry === 'undefined') {
|
||||||
|
console.warn('[Switchboard] PanelRegistry not available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
open(name) { PanelRegistry.open(name); },
|
||||||
|
close(name) { PanelRegistry.close(name); },
|
||||||
|
toggle(name) { PanelRegistry.toggle(name); },
|
||||||
|
isOpen(name) { return PanelRegistry.isOpen(name); },
|
||||||
|
active() { return PanelRegistry.active(); },
|
||||||
|
cycle() { PanelRegistry.cycle(); },
|
||||||
|
register(name, opts) { PanelRegistry.register(name, opts); },
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Pipe/Filter Pipeline ─────────────────────────────
|
// ── Pipe/Filter Pipeline ─────────────────────────────
|
||||||
|
|||||||
@@ -223,8 +223,8 @@ Object.assign(UI, {
|
|||||||
p.editorKeymap = mode;
|
p.editorKeymap = mode;
|
||||||
localStorage.setItem('cs-appearance', JSON.stringify(p));
|
localStorage.setItem('cs-appearance', JSON.stringify(p));
|
||||||
// Notify open editors
|
// Notify open editors
|
||||||
if (typeof Events !== 'undefined' && Events.emit) {
|
if (typeof sw !== 'undefined') {
|
||||||
Events.emit('keymap.changed', { mode });
|
sw.emit('keymap.changed', { mode });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -257,9 +257,9 @@ Object.assign(UI, {
|
|||||||
document.documentElement.setAttribute('data-theme', resolved);
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
}
|
}
|
||||||
// Publish event for CM6 editors and extensions
|
// Publish event for CM6 editors and extensions
|
||||||
if (typeof Events !== 'undefined' && Events.emit) {
|
if (typeof sw !== 'undefined') {
|
||||||
const resolved = typeof Theme !== 'undefined' ? Theme.resolved() : mode;
|
const resolved = sw.theme?.current || mode;
|
||||||
Events.emit('theme.changed', { mode, resolved });
|
sw.emit('theme.changed', { mode, resolved });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user