Files
QSfera/Server/services/keepernotes/pkg/notes/store.go
T
Курнат Андрей 2315f25754 Initial QSfera import
2026-06-07 10:20:04 +03:00

230 lines
5.0 KiB
Go

package notes
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type Note struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
BodyFormats []BodyFormat `json:"bodyFormats"`
Owner string `json:"owner"`
Collaborators []string `json:"collaborators"`
Color string `json:"color"`
UpdatedAt int64 `json:"updatedAt"`
SortOrder int64 `json:"sortOrder"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
Deleted bool `json:"deleted"`
Checklist bool `json:"checklist"`
Items []Item `json:"checklistItems"`
Labels []string `json:"labels"`
ReminderAt *int64 `json:"reminderAt"`
Images []Image `json:"images"`
Audios []Audio `json:"audios"`
}
type Item struct {
ID string `json:"id"`
Text string `json:"text"`
Checked bool `json:"checked"`
}
type BodyFormat struct {
Start int `json:"start"`
End int `json:"end"`
Style string `json:"style"`
}
type Image struct {
ID string `json:"id"`
MimeType string `json:"mimeType"`
Base64 string `json:"base64"`
}
type Audio struct {
ID string `json:"id"`
MimeType string `json:"mimeType"`
Base64 string `json:"base64"`
DurationMillis int64 `json:"durationMillis"`
}
type Store interface {
List() []Note
Upsert(note Note) (Note, error)
Delete(id string) error
}
type FileStore struct {
mu sync.Mutex
path string
notes map[string]Note
}
func OpenFileStore(path string) (*FileStore, error) {
store := &FileStore{
path: path,
notes: map[string]Note{},
}
if err := store.load(); err != nil {
return nil, err
}
return store, nil
}
func (s *FileStore) List() []Note {
s.mu.Lock()
defer s.mu.Unlock()
return sortedNotes(s.notes)
}
func (s *FileStore) Upsert(note Note) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
note.ID = strings.TrimSpace(note.ID)
if note.ID == "" {
return Note{}, errors.New("note id is required")
}
if note.Color == "" {
note.Color = "#FFF475"
}
if note.UpdatedAt <= 0 {
note.UpdatedAt = time.Now().UnixMilli()
}
if note.SortOrder <= 0 {
note.SortOrder = note.UpdatedAt
}
note.Owner = strings.TrimSpace(note.Owner)
note.Collaborators = normalizedStrings(note.Collaborators, 50)
s.notes[note.ID] = note
if err := s.persistLocked(); err != nil {
return Note{}, err
}
return note, nil
}
func (s *FileStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.notes, strings.TrimSpace(id))
return s.persistLocked()
}
func (s *FileStore) load() error {
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create data directory: %w", err)
}
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s.persistLocked()
}
if err != nil {
return fmt.Errorf("read data file: %w", err)
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
var loaded []Note
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("decode data file: %w", err)
}
for _, note := range loaded {
if strings.TrimSpace(note.ID) == "" {
continue
}
s.notes[note.ID] = note
}
return nil
}
func (s *FileStore) persistLocked() error {
notes := sortedNotes(s.notes)
dir := filepath.Dir(s.path)
tmp, err := os.CreateTemp(dir, ".keeper-notes-*.tmp")
if err != nil {
return fmt.Errorf("create temp data file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
encoder := json.NewEncoder(tmp)
encoder.SetIndent("", " ")
if err := encoder.Encode(notes); err != nil {
_ = tmp.Close()
return fmt.Errorf("encode data file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp data file: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(s.path)
if retryErr := os.Rename(tmpName, s.path); retryErr != nil {
return fmt.Errorf("replace data file: %w", retryErr)
}
}
return nil
}
func normalizedStrings(values []string, limit int) []string {
seen := map[string]struct{}{}
normalized := []string{}
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, trimmed)
if len(normalized) >= limit {
break
}
}
return normalized
}
func sortedNotes(noteMap map[string]Note) []Note {
notes := make([]Note, 0, len(noteMap))
for _, note := range noteMap {
notes = append(notes, note)
}
sort.Slice(notes, func(i, j int) bool {
leftOrder := notes[i].SortOrder
if leftOrder <= 0 {
leftOrder = notes[i].UpdatedAt
}
rightOrder := notes[j].SortOrder
if rightOrder <= 0 {
rightOrder = notes[j].UpdatedAt
}
if leftOrder == rightOrder {
if notes[i].UpdatedAt != notes[j].UpdatedAt {
return notes[i].UpdatedAt > notes[j].UpdatedAt
}
return notes[i].ID < notes[j].ID
}
return leftOrder > rightOrder
})
return notes
}