Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,105 @@
package notes
import (
"encoding/json"
"net/http"
"net/url"
"strings"
)
type Server struct {
store Store
}
func NewServer(store Store) http.Handler {
server := &Server{store: store}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", server.handleHealth)
mux.HandleFunc("/api/notes", server.handleCollection)
mux.HandleFunc("/api/notes/", server.handleItem)
return withCORS(mux)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, s.store.List())
case http.MethodPost:
var note Note
if err := json.NewDecoder(r.Body).Decode(&note); err != nil {
writeError(w, http.StatusBadRequest, "invalid note json")
return
}
saved, err := s.store.Upsert(note)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, saved)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s *Server) handleItem(w http.ResponseWriter, r *http.Request) {
id, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/api/notes/"))
if err != nil || strings.TrimSpace(id) == "" {
writeError(w, http.StatusBadRequest, "note id is required")
return
}
switch r.Method {
case http.MethodPut:
var note Note
if err := json.NewDecoder(r.Body).Decode(&note); err != nil {
writeError(w, http.StatusBadRequest, "invalid note json")
return
}
note.ID = id
saved, err := s.store.Upsert(note)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, saved)
case http.MethodDelete:
if err := s.store.Delete(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Accept")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
@@ -0,0 +1,229 @@
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
}
@@ -0,0 +1,94 @@
package notes
import (
"path/filepath"
"testing"
)
func TestFileStoreUpsertListDeleteAndPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "notes.json")
reminderAt := int64(2000)
store, err := OpenFileStore(path)
if err != nil {
t.Fatalf("OpenFileStore() error = %v", err)
}
if _, err := store.Upsert(Note{ID: "old", Title: "Old", Color: "#FFFFFF", UpdatedAt: 100}); err != nil {
t.Fatalf("Upsert(old) error = %v", err)
}
if _, err := store.Upsert(Note{
ID: "new",
Title: "New",
Body: "Formatted body",
BodyFormats: []BodyFormat{{Start: 0, End: 9, Style: "bold"}},
Owner: "owner-1",
Collaborators: []string{"friend@example.org", "friend@example.org", " "},
Color: "#FFF475",
UpdatedAt: 200,
SortOrder: 300,
Pinned: true,
Archived: true,
Deleted: true,
Checklist: true,
Items: []Item{{ID: "item-1", Text: "Buy milk", Checked: true}},
Labels: []string{"Home", "Errands"},
ReminderAt: &reminderAt,
Images: []Image{{ID: "image-1", MimeType: "image/jpeg", Base64: "abc123"}},
Audios: []Audio{{ID: "audio-1", MimeType: "audio/mp4", Base64: "def456", DurationMillis: 1200}},
}); err != nil {
t.Fatalf("Upsert(new) error = %v", err)
}
notes := store.List()
if len(notes) != 2 {
t.Fatalf("List() length = %d, want 2", len(notes))
}
if notes[0].ID != "new" {
t.Fatalf("List()[0].ID = %q, want %q", notes[0].ID, "new")
}
if notes[0].SortOrder != 300 {
t.Fatalf("List()[0].SortOrder = %d, want 300", notes[0].SortOrder)
}
if notes[0].Body != "Formatted body" || len(notes[0].BodyFormats) != 1 || notes[0].BodyFormats[0].Style != "bold" {
t.Fatalf("List()[0] body formatting = body:%q formats:%+v, want one bold range", notes[0].Body, notes[0].BodyFormats)
}
if notes[0].Owner != "owner-1" || len(notes[0].Collaborators) != 1 || notes[0].Collaborators[0] != "friend@example.org" {
t.Fatalf("List()[0] sharing = owner:%q collaborators:%+v, want one collaborator", notes[0].Owner, notes[0].Collaborators)
}
if !notes[0].Pinned || !notes[0].Archived || !notes[0].Deleted {
t.Fatalf("List()[0] state = pinned:%v archived:%v deleted:%v, want all true", notes[0].Pinned, notes[0].Archived, notes[0].Deleted)
}
if !notes[0].Checklist || len(notes[0].Items) != 1 || notes[0].Items[0].Text != "Buy milk" || !notes[0].Items[0].Checked {
t.Fatalf("List()[0] checklist = %+v, want one checked item", notes[0].Items)
}
if len(notes[0].Labels) != 2 || notes[0].Labels[0] != "Home" || notes[0].Labels[1] != "Errands" {
t.Fatalf("List()[0] labels = %+v, want Home and Errands", notes[0].Labels)
}
if notes[0].ReminderAt == nil || *notes[0].ReminderAt != reminderAt {
t.Fatalf("List()[0] ReminderAt = %v, want %d", notes[0].ReminderAt, reminderAt)
}
if len(notes[0].Images) != 1 || notes[0].Images[0].ID != "image-1" || notes[0].Images[0].Base64 != "abc123" {
t.Fatalf("List()[0] images = %+v, want image-1", notes[0].Images)
}
if len(notes[0].Audios) != 1 || notes[0].Audios[0].ID != "audio-1" || notes[0].Audios[0].Base64 != "def456" {
t.Fatalf("List()[0] audios = %+v, want audio-1", notes[0].Audios)
}
if err := store.Delete("new"); err != nil {
t.Fatalf("Delete(new) error = %v", err)
}
reopened, err := OpenFileStore(path)
if err != nil {
t.Fatalf("OpenFileStore(reopen) error = %v", err)
}
notes = reopened.List()
if len(notes) != 1 {
t.Fatalf("reopened List() length = %d, want 1", len(notes))
}
if notes[0].ID != "old" {
t.Fatalf("reopened List()[0].ID = %q, want %q", notes[0].ID, "old")
}
}