230 lines
5.0 KiB
Go
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
|
|
}
|