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.
|
||||
|
||||
- [ ] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
|
||||
- [ ] Core surface migration: chat, editor, notes, settings
|
||||
- [ ] Phase 5 FE decomp: `import`/`export` statements
|
||||
- [ ] Memory compaction: summarize, confidence decay, prune
|
||||
- [x] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
|
||||
- [x] Core surface migration: chat, editor, notes, settings
|
||||
- [x] Phase 5 FE decomp: `import`/`export` statements
|
||||
- [x] Memory compaction: summarize, confidence decay, prune
|
||||
|
||||
### v0.30.2 — Workflow Packages
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"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/store"
|
||||
)
|
||||
|
||||
// MemoryHandler provides REST endpoints for memory management.
|
||||
type MemoryHandler struct {
|
||||
stores store.Stores
|
||||
stores store.Stores
|
||||
compactor *memory.Compactor
|
||||
}
|
||||
|
||||
// NewMemoryHandler creates a new memory handler.
|
||||
@@ -19,6 +21,11 @@ func NewMemoryHandler(stores store.Stores) *MemoryHandler {
|
||||
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.
|
||||
// GET /api/v1/memories?status=active&query=...
|
||||
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})
|
||||
}
|
||||
|
||||
// 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)
|
||||
tools.RegisterTaskTools(stores)
|
||||
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
|
||||
memCompactor := memory.NewCompactor(stores, roleResolver)
|
||||
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
|
||||
memScanner.Start()
|
||||
defer memScanner.Stop()
|
||||
@@ -909,12 +910,14 @@ func main() {
|
||||
|
||||
// Memory management (v0.18.0)
|
||||
memH := handlers.NewMemoryHandler(stores)
|
||||
memH.SetCompactor(memCompactor)
|
||||
protected.GET("/memories", memH.ListMyMemories)
|
||||
protected.PUT("/memories/:id", memH.UpdateMemory)
|
||||
protected.DELETE("/memories/:id", memH.DeleteMemory)
|
||||
protected.POST("/memories/:id/approve", memH.ApproveMemory)
|
||||
protected.POST("/memories/:id/reject", memH.RejectMemory)
|
||||
protected.GET("/memories/count", memH.MemoryCount)
|
||||
protected.POST("/memories/compact", memH.CompactMemories)
|
||||
|
||||
// Teams (user: my teams)
|
||||
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,45 +95,81 @@ func (s *Scanner) scan() {
|
||||
// If key is missing (err != nil or val == nil), extraction is enabled.
|
||||
}
|
||||
|
||||
// ── Extraction ──
|
||||
candidates, err := s.findCandidates(ctx)
|
||||
if err != nil {
|
||||
log.Printf("⚠ memory scanner: find candidates: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
if len(candidates) > 0 {
|
||||
sem := make(chan struct{}, s.cfg.Concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, c := range candidates {
|
||||
key := c.ChannelID + ":" + c.UserID
|
||||
if _, loaded := s.inFlight.LoadOrStore(key, true); loaded {
|
||||
continue
|
||||
}
|
||||
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
|
||||
go func(cand candidate) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID)
|
||||
|
||||
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, cand.PersonaID); err != nil {
|
||||
log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v",
|
||||
cand.ChannelID, cand.UserID, err)
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Semaphore for concurrency control
|
||||
sem := make(chan struct{}, s.cfg.Concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, c := range candidates {
|
||||
key := c.ChannelID + ":" + c.UserID
|
||||
if _, loaded := s.inFlight.LoadOrStore(key, true); loaded {
|
||||
continue // already in progress
|
||||
}
|
||||
|
||||
sem <- struct{}{} // acquire
|
||||
wg.Add(1)
|
||||
|
||||
go func(cand candidate) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // release
|
||||
defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID)
|
||||
|
||||
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, cand.PersonaID); err != nil {
|
||||
log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v",
|
||||
cand.ChannelID, cand.UserID, err)
|
||||
// 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
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// 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.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
||||
</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/api.js?v={{.Version}}"></script>
|
||||
<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-admin.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/persona-kb.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-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/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/note-graph.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/memory-ui.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/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/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/workflow-api.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/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/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/note-graph.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/panels.js?v={{.Version}}"></script>
|
||||
{{/* 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 nonce="{{.CSPNonce}}">
|
||||
// Notes surface boot: open notes panel in standalone mode
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
{{/* Scripts */}}
|
||||
{{define "scripts-settings"}}
|
||||
<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/git-credentials-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)
|
||||
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)
|
||||
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(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
|
||||
if (typeof Events !== 'undefined') {
|
||||
Events.on('role.fallback', (payload) => {
|
||||
if (typeof sw !== 'undefined') {
|
||||
sw.on('role.fallback', (payload) => {
|
||||
const banner = document.getElementById('roleFallbackBanner');
|
||||
if (!banner) return;
|
||||
const msg = payload?.message || 'A model role is using its fallback provider.';
|
||||
@@ -704,8 +704,8 @@ async function deleteAdminExtension(id, name) {
|
||||
var _extEditors = {};
|
||||
|
||||
// Listen for theme changes to update CM6 editors
|
||||
if (typeof Events !== 'undefined') {
|
||||
Events.on('theme.changed', (data) => {
|
||||
if (typeof sw !== 'undefined') {
|
||||
sw.on('theme.changed', (data) => {
|
||||
const isDark = data.resolved === 'dark';
|
||||
for (const editors of Object.values(_extEditors)) {
|
||||
editors.manifest?.setDarkMode(isDark);
|
||||
@@ -713,8 +713,7 @@ if (typeof Events !== 'undefined') {
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for keymap changes to update CM6 editors in real time
|
||||
Events.on('keymap.changed', (data) => {
|
||||
sw.on('keymap.changed', (data) => {
|
||||
const mode = data.mode || 'standard';
|
||||
for (const editors of Object.values(_extEditors)) {
|
||||
editors.manifest?.setKeymap(mode);
|
||||
|
||||
@@ -91,7 +91,7 @@ async function startApp() {
|
||||
}
|
||||
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
UI.showAdminButton(sw.isAdmin);
|
||||
UI.restoreAppearance();
|
||||
|
||||
// Load models for all surfaces (editor needs them for model selector)
|
||||
@@ -100,7 +100,7 @@ async function startApp() {
|
||||
// Connect EventBus WebSocket (non-blocking)
|
||||
try {
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
Events.on('ws.connected', () => {
|
||||
sw.on('ws.connected', () => {
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -195,8 +195,8 @@ async function _startChatSurface() {
|
||||
initListeners();
|
||||
|
||||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||
Events.on('role.fallback', (payload) => {
|
||||
if (App.user?.role !== 'admin') return;
|
||||
sw.on('role.fallback', (payload) => {
|
||||
if (!sw.isAdmin) return;
|
||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||
showFallbackBanner(data);
|
||||
});
|
||||
|
||||
@@ -298,7 +298,7 @@ async function selectChat(chatId) {
|
||||
updateChatTokenCount();
|
||||
|
||||
// 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
|
||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
|
||||
@@ -437,7 +437,7 @@ async function newChat() {
|
||||
}
|
||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||
// 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__ || ''}/`);
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@ async function newGroupChat() {
|
||||
App.chats.unshift(chat);
|
||||
App.setActive(chat.id, 'group');
|
||||
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}`);
|
||||
|
||||
// Load the channel model roster (populated by participant auto-add)
|
||||
@@ -708,7 +708,7 @@ async function sendMessage() {
|
||||
App.setActive(chat.id, 'direct');
|
||||
UI.renderChatList();
|
||||
// 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}`);
|
||||
} 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)
|
||||
// When a persona @mentions another persona, the follow-up completion
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
Events.on('typing.start', (payload) => {
|
||||
sw.on('typing.start', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
if (payload.participant_type !== 'persona') return;
|
||||
|
||||
@@ -1192,16 +1192,16 @@ function _initChatListeners() {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
|
||||
Events.on('typing.stop', (payload) => {
|
||||
sw.on('typing.stop', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
document.getElementById('userTypingIndicator')?.remove();
|
||||
});
|
||||
|
||||
// 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.user_id === API.user?.id) return; // ignore self
|
||||
if (payload.user_id === sw.user?.id) return; // ignore self
|
||||
|
||||
// Show or refresh typing indicator
|
||||
let typing = document.getElementById('userTypingIndicator');
|
||||
@@ -1231,7 +1231,7 @@ function _initChatListeners() {
|
||||
});
|
||||
|
||||
// v0.23.2: User @mention notification
|
||||
Events.on('user.mentioned', (payload) => {
|
||||
sw.on('user.mentioned', (payload) => {
|
||||
if (!payload) return;
|
||||
const channelId = payload.channel_id;
|
||||
// Increment unread badge on the channel sidebar item
|
||||
@@ -1246,14 +1246,14 @@ function _initChatListeners() {
|
||||
});
|
||||
|
||||
// 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;
|
||||
App.presence[payload.user_id] = payload.status || 'offline';
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
});
|
||||
|
||||
// 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;
|
||||
const ac = App.activeConversation;
|
||||
if (ac && ac.id === payload.channel_id) {
|
||||
@@ -1264,7 +1264,7 @@ function _initChatListeners() {
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow completed — refresh stage bar
|
||||
Events.on('workflow.completed', (payload) => {
|
||||
sw.on('workflow.completed', (payload) => {
|
||||
if (!payload) return;
|
||||
const ac = App.activeConversation;
|
||||
if (ac && ac.id === payload.channel_id) {
|
||||
@@ -1274,9 +1274,9 @@ function _initChatListeners() {
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow assigned — refresh queue + toast
|
||||
Events.on('workflow.assigned', (payload) => {
|
||||
sw.on('workflow.assigned', (payload) => {
|
||||
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');
|
||||
}
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
const name = window.prompt('Workspace name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
const userId = API.user?.id || window.__USER__?.id;
|
||||
const userId = sw.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
@@ -113,7 +113,7 @@
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating…';
|
||||
try {
|
||||
const userId = API.user?.id || window.__USER__?.id;
|
||||
const userId = sw.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
|
||||
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);
|
||||
852
src/js/notes.js
852
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;
|
||||
var _notesSelectMode = false;
|
||||
var _selectedNoteIds = new Set();
|
||||
var _notesSort = 'updated_desc';
|
||||
var _noteEditor = null; // CM6 noteEditor instance
|
||||
// ── Singleton accessor ──
|
||||
function _np() { return NotePanel.primary; }
|
||||
|
||||
// ── Notes ─────────────────────────────────────
|
||||
|
||||
// ── Public API (delegated to NotePanel.primary) ──
|
||||
|
||||
async function openNotes() {
|
||||
// Toggle: if notes is active, close it; otherwise open it
|
||||
if (PanelRegistry.isOpen('notes')) {
|
||||
PanelRegistry.close('notes');
|
||||
return;
|
||||
}
|
||||
PanelRegistry.open('notes');
|
||||
_exitSelectMode();
|
||||
_np()?._exitSelectMode();
|
||||
}
|
||||
|
||||
async function loadNotesList(folder, searchQuery) {
|
||||
const list = document.getElementById('notesList');
|
||||
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 */ }
|
||||
return _np()?.loadNotesList(folder, searchQuery);
|
||||
}
|
||||
|
||||
function showNotesList() {
|
||||
document.getElementById('notesListView').style.display = '';
|
||||
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>';
|
||||
return _np()?.showNotesList();
|
||||
}
|
||||
|
||||
var _currentNote = null; // cached note for read mode
|
||||
|
||||
function copyNoteContent() {
|
||||
if (!_currentNote) return;
|
||||
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
|
||||
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
|
||||
async function loadNoteFolders() {
|
||||
return _np()?.loadNoteFolders();
|
||||
}
|
||||
|
||||
async function openNoteEditor(noteId) {
|
||||
document.getElementById('notesListView').style.display = 'none';
|
||||
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';
|
||||
return _np()?.openNoteEditor(noteId);
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||
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'); }
|
||||
return _np()?.saveNote();
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!_editingNoteId) return;
|
||||
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'); }
|
||||
return _np()?.deleteNote();
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
async function saveMessageToNote(msgIndex) {
|
||||
return _np()?.saveMessageToNote(msgIndex);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
function _toggleNoteSelect(id, forceState) {
|
||||
return _np()?._toggleNoteSelect(id, forceState);
|
||||
}
|
||||
|
||||
function _navigateToLinkedNote(title) {
|
||||
return _np()?._navigateToLinkedNote(title);
|
||||
}
|
||||
|
||||
function toggleBacklinks() {
|
||||
const list = document.getElementById('noteBacklinksList');
|
||||
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
|
||||
return _np()?.toggleBacklinks();
|
||||
}
|
||||
|
||||
// ── Wikilink Read-Mode Rendering ──
|
||||
|
||||
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);
|
||||
function _toggleNoteSelectCb(el, noteId) {
|
||||
return _np()?._toggleNoteSelectCb(el, noteId);
|
||||
}
|
||||
|
||||
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
|
||||
async function _resolveTransclusions(containerEl) {
|
||||
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 ![[]] )
|
||||
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>';
|
||||
}
|
||||
}
|
||||
function _noteItemClick(noteId) {
|
||||
return _np()?._noteItemClick(noteId);
|
||||
}
|
||||
|
||||
// ── Daily Notes ─────────────────
|
||||
// Read-only accessors for backward compat (sb.register registered these as values)
|
||||
function _currentNote() { return _np()?._currentNote; }
|
||||
function _editingNoteId() { return _np()?._editingNoteId; }
|
||||
|
||||
async function openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
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) ──
|
||||
// ── Panel Registration ──
|
||||
// Creates the singleton NotePanel.primary and registers with PanelRegistry.
|
||||
|
||||
function _registerNotesPanel() {
|
||||
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 ──
|
||||
const el = document.createElement('div');
|
||||
@@ -760,12 +96,12 @@ function _registerNotesPanel() {
|
||||
<div id="notesListView">
|
||||
<div class="notes-toolbar">
|
||||
<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="notesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
|
||||
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">☑ Select</button>
|
||||
<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>
|
||||
<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>
|
||||
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>
|
||||
</div>
|
||||
<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 class="notes-filter-row">
|
||||
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
|
||||
@@ -786,7 +122,7 @@ function _registerNotesPanel() {
|
||||
<!-- Editor view -->
|
||||
<div id="notesEditorView" style="display:none;">
|
||||
<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>
|
||||
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</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)">
|
||||
</div>
|
||||
<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>
|
||||
<!-- Read mode -->
|
||||
@@ -825,9 +161,9 @@ function _registerNotesPanel() {
|
||||
<!-- Graph view -->
|
||||
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
|
||||
<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 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>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
|
||||
@@ -837,67 +173,65 @@ function _registerNotesPanel() {
|
||||
</div>`;
|
||||
body.appendChild(el);
|
||||
|
||||
// ── Create NotePanel singleton ──
|
||||
const np = NotePanel.create({ container: el });
|
||||
NotePanel.primary = np;
|
||||
|
||||
// ── Wire listeners ──
|
||||
// Sidebar trigger (lives outside this panel)
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
|
||||
// Toolbar buttons
|
||||
el.querySelector('#notesNewBtn').addEventListener('click', () => openNoteEditor(null));
|
||||
el.querySelector('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
|
||||
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
|
||||
if (typeof openNoteGraph === 'function') openNoteGraph();
|
||||
});
|
||||
el.querySelector('#notesTodayBtn').addEventListener('click', openDailyNote);
|
||||
el.querySelector('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
|
||||
el.querySelector('#notesGraphBackBtn').addEventListener('click', () => {
|
||||
if (typeof closeNoteGraph === 'function') closeNoteGraph();
|
||||
});
|
||||
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
|
||||
if (_notesSelectMode) _exitSelectMode();
|
||||
else _enterSelectMode();
|
||||
if (np._notesSelectMode) np._exitSelectMode();
|
||||
else np._enterSelectMode();
|
||||
});
|
||||
|
||||
// Editor header buttons
|
||||
el.querySelector('#notesBackBtn').addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
|
||||
el.querySelector('#noteSaveBtn').addEventListener('click', saveNote);
|
||||
el.querySelector('#noteDeleteBtn').addEventListener('click', deleteNote);
|
||||
el.querySelector('#noteDeleteBtn2').addEventListener('click', deleteNote);
|
||||
el.querySelector('#noteEditBtn').addEventListener('click', _showNoteEditMode);
|
||||
el.querySelector('#notePreviewBtn').addEventListener('click', _showNotePreview);
|
||||
el.querySelector('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
|
||||
el.querySelector('#noteSaveBtn').addEventListener('click', () => np.saveNote());
|
||||
el.querySelector('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
|
||||
el.querySelector('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
|
||||
el.querySelector('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
|
||||
el.querySelector('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
|
||||
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
|
||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||||
else showNotesList();
|
||||
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
|
||||
else np.showNotesList();
|
||||
});
|
||||
|
||||
// Filters
|
||||
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
|
||||
document.getElementById('notesSearchInput').value = '';
|
||||
_exitSelectMode();
|
||||
loadNotesList(e.target.value);
|
||||
np.$('#notesSearchInput').value = '';
|
||||
np._exitSelectMode();
|
||||
np.loadNotesList(e.target.value);
|
||||
});
|
||||
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
|
||||
_notesSort = e.target.value;
|
||||
_exitSelectMode();
|
||||
loadNotesList();
|
||||
np._notesSort = e.target.value;
|
||||
np._exitSelectMode();
|
||||
np.loadNotesList();
|
||||
});
|
||||
|
||||
// Multi-select controls
|
||||
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
|
||||
_toggleSelectAll(e.target.checked);
|
||||
np._toggleSelectAll(e.target.checked);
|
||||
});
|
||||
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', _bulkDeleteSelected);
|
||||
el.querySelector('#notesCancelSelectBtn').addEventListener('click', _exitSelectMode);
|
||||
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
|
||||
el.querySelector('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
|
||||
|
||||
// Search with debounce
|
||||
let _notesSearchTimer;
|
||||
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
|
||||
clearTimeout(_notesSearchTimer);
|
||||
const q = e.target.value.trim();
|
||||
_notesSearchTimer = setTimeout(() => {
|
||||
_exitSelectMode();
|
||||
np._exitSelectMode();
|
||||
if (q.length >= 2) {
|
||||
document.getElementById('notesFolderFilter').value = '';
|
||||
loadNotesList(null, q);
|
||||
np.$('#notesFolderFilter').value = '';
|
||||
np.loadNotesList(null, q);
|
||||
} else if (q.length === 0) {
|
||||
loadNotesList();
|
||||
np.loadNotesList();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
@@ -907,29 +241,28 @@ function _registerNotesPanel() {
|
||||
element: el,
|
||||
label: 'Notes',
|
||||
onOpen() {
|
||||
// Ensure notes are loaded whenever the panel opens
|
||||
loadNotesList();
|
||||
loadNoteFolders();
|
||||
np.loadNotesList();
|
||||
np.loadNoteFolders();
|
||||
},
|
||||
onClose() {
|
||||
_destroyNoteEditor();
|
||||
np._destroyNoteEditor();
|
||||
},
|
||||
saveState() {
|
||||
const listView = document.getElementById('notesListView');
|
||||
const editorView = document.getElementById('notesEditorView');
|
||||
const graphView = document.getElementById('notesGraphView');
|
||||
const listView = np.$('#notesListView');
|
||||
const editorView = np.$('#notesEditorView');
|
||||
const graphView = np.$('#notesGraphView');
|
||||
let viewMode = 'list';
|
||||
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
|
||||
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
|
||||
return {
|
||||
scrollTop: listView?.scrollTop || 0,
|
||||
editingNoteId: _editingNoteId,
|
||||
editingNoteId: np._editingNoteId,
|
||||
viewMode,
|
||||
};
|
||||
},
|
||||
restoreState(state) {
|
||||
if (state.scrollTop) {
|
||||
const listView = document.getElementById('notesListView');
|
||||
const listView = np.$('#notesListView');
|
||||
if (listView) listView.scrollTop = state.scrollTop;
|
||||
}
|
||||
},
|
||||
@@ -939,21 +272,6 @@ function _registerNotesPanel() {
|
||||
/** @deprecated — listeners are now wired in _registerNotesPanel() */
|
||||
function _initNotesListeners() {
|
||||
// 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 ─────────────────────────────────
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
//
|
||||
// Exports: window.sb
|
||||
|
||||
'use strict';
|
||||
|
||||
const sb = {
|
||||
_actions: new Map(),
|
||||
_namespaces: new Map(),
|
||||
|
||||
@@ -255,16 +255,45 @@ const Switchboard = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize notes in a container element.
|
||||
* NOTE: v0.28.5 stub — notes lacks a clean create() factory.
|
||||
* Full component extraction is pre-1.0 tech debt.
|
||||
* Create a NotePanel instance in a container element.
|
||||
* Wraps NotePanel.create() with SDK-aware defaults.
|
||||
*
|
||||
* @param {HTMLElement} container
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { projectId }
|
||||
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
|
||||
*/
|
||||
sw.notes = function (container, opts) {
|
||||
console.warn('[Switchboard] sw.notes() not yet available — notes component needs refactor (pre-1.0)');
|
||||
return null;
|
||||
if (typeof NotePanel === 'undefined') {
|
||||
console.error('[Switchboard] NotePanel not available');
|
||||
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 ─────────────────────────────
|
||||
|
||||
@@ -223,8 +223,8 @@ Object.assign(UI, {
|
||||
p.editorKeymap = mode;
|
||||
localStorage.setItem('cs-appearance', JSON.stringify(p));
|
||||
// Notify open editors
|
||||
if (typeof Events !== 'undefined' && Events.emit) {
|
||||
Events.emit('keymap.changed', { mode });
|
||||
if (typeof sw !== 'undefined') {
|
||||
sw.emit('keymap.changed', { mode });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -257,9 +257,9 @@ Object.assign(UI, {
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
// Publish event for CM6 editors and extensions
|
||||
if (typeof Events !== 'undefined' && Events.emit) {
|
||||
const resolved = typeof Theme !== 'undefined' ? Theme.resolved() : mode;
|
||||
Events.emit('theme.changed', { mode, resolved });
|
||||
if (typeof sw !== 'undefined') {
|
||||
const resolved = sw.theme?.current || mode;
|
||||
sw.emit('theme.changed', { mode, resolved });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user