package notes import ( "fmt" "os" "path/filepath" "time" "verstak/internal/core/files" "verstak/internal/core/nodes" "verstak/internal/core/storage" "verstak/internal/core/util" ) // Record represents a note entry (links a node to a file). type Record struct { NodeID string `json:"node_id"` FileID string `json:"file_id"` Format string `json:"format"` Encrypted bool `json:"encrypted"` } // Service handles markdown notes. type Service struct { db *storage.DB vaultRoot string nodes *nodes.Repository files *files.Service } // NewService creates a note service. func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fileSvc *files.Service) *Service { return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc} } // Create makes a new note node, an empty .md file, and links them. func (s *Service) Create(parentID, title string) (*nodes.Node, *files.Record, error) { node, err := s.nodes.Create(parentID, nodes.TypeNote, title) if err != nil { return nil, nil, fmt.Errorf("create node: %w", err) } slug := node.Slug if slug == "" { slug = "note" } filename := slug + ".md" destDir := filepath.Join(s.vaultRoot, "spaces") os.MkdirAll(destDir, 0o750) dest := filepath.Join(destDir, filename) if _, err := os.Stat(dest); err == nil { filename = fmt.Sprintf("%s_%s.md", slug, node.ID[:8]) dest = filepath.Join(destDir, filename) } // Write initial content. if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil { return nil, nil, fmt.Errorf("write: %w", err) } // Register file record. relPath, _ := filepath.Rel(s.vaultRoot, dest) fileRec, err := insertFileRecord(s.db, node.ID, filename, relPath, "vault", 0) if err != nil { return nil, nil, fmt.Errorf("insert file: %w", err) } // Link. _, err = s.db.Exec( `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, node.ID, fileRec.ID, "markdown") if err != nil { return nil, nil, fmt.Errorf("link note: %w", err) } return node, fileRec, nil } // Read returns the content of a note. func (s *Service) Read(nodeID string) (string, error) { var filePath, storageMode string err := s.db.QueryRow( `SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode) if err != nil { return "", fmt.Errorf("query note: %w", err) } var abs string if storageMode == "vault" { abs = filepath.Join(s.vaultRoot, filePath) } else { abs = filePath } data, err := os.ReadFile(abs) if err != nil { return "", fmt.Errorf("read: %w", err) } return string(data), nil } // Save writes new content, backing up the old version. func (s *Service) Save(nodeID, content string) error { var filePath, storageMode string err := s.db.QueryRow( `SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode) if err != nil { return fmt.Errorf("query: %w", err) } var abs string if storageMode == "vault" { abs = filepath.Join(s.vaultRoot, filePath) } else { abs = filePath } // Backup old version. if info, err := os.Stat(abs); err == nil && info.Size() > 0 { histDir := filepath.Join(s.vaultRoot, ".verstak", "history") os.MkdirAll(histDir, 0o750) name := filepath.Base(abs) backup := filepath.Join(histDir, fmt.Sprintf("%s_%d.bak", name, time.Now().Unix())) os.WriteFile(backup, mustRead(abs), 0o640) } if err := os.WriteFile(abs, []byte(content), 0o640); err != nil { return fmt.Errorf("write: %w", err) } // Update file size. info, _ := os.Stat(abs) size := int64(0) if info != nil { size = info.Size() } _, err = s.db.Exec( `UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`, size, utcNow(), filePath, storageMode) return err } // Delete soft-deletes the note node. func (s *Service) Delete(nodeID string) error { return s.nodes.SoftDelete(nodeID) } // Load looks up the note record for a node. func (s *Service) Load(nodeID string) (*Record, error) { var rec Record var enc int err := s.db.QueryRow( `SELECT node_id, file_id, format, encrypted FROM notes WHERE node_id=?`, nodeID, ).Scan(&rec.NodeID, &rec.FileID, &rec.Format, &enc) if err != nil { return nil, err } rec.Encrypted = enc == 1 return &rec, nil } // --- helpers --- func insertFileRecord(db *storage.DB, nodeID, filename, relPath, mode string, size int64) (*files.Record, error) { rec := &files.Record{ ID: util.UUID7(), NodeID: nodeID, Filename: filename, Path: relPath, StorageMode: mode, Size: size, MIME: "text/plain", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } _, err := db.Exec( `INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime, created_at,updated_at,missing) VALUES (?,?,?,?,?,?,?,?,?,0)`, rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode, rec.Size, rec.MIME, rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339)) if err != nil { return nil, err } return rec, nil } func mustRead(path string) []byte { data, err := os.ReadFile(path) if err != nil { return nil } return data } func utcNow() string { return time.Now().UTC().Format(time.RFC3339) }