Initial QSfera import
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# Keeper Notes Service
|
||||
|
||||
Small REST service for the `G:\Repos\QSfera\Keeper` Android app.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.json
|
||||
```
|
||||
|
||||
The Android emulator can reach the host service through `http://10.0.2.2:8098`.
|
||||
|
||||
## API
|
||||
|
||||
* `GET /healthz`
|
||||
* `GET /api/notes`
|
||||
* `PUT /api/notes/{id}`
|
||||
* `DELETE /api/notes/{id}`
|
||||
|
||||
Notes are stored in a JSON file configured by `-data` or `KEEPER_NOTES_DATA`.
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/services/keepernotes/pkg/notes"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := getenv("KEEPER_NOTES_ADDR", ":8098")
|
||||
dataPath := getenv("KEEPER_NOTES_DATA", "keeper-notes.json")
|
||||
|
||||
flag.StringVar(&addr, "addr", addr, "HTTP listen address")
|
||||
flag.StringVar(&dataPath, "data", dataPath, "JSON file used to store notes")
|
||||
flag.Parse()
|
||||
|
||||
store, err := notes.OpenFileStore(dataPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open note store: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("keeper notes service listening on %s, data file %s", addr, dataPath)
|
||||
if err := http.ListenAndServe(addr, notes.NewServer(store)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -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(¬e); 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(¬e); 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user