From c47b9a83574954039ec31f8dd34eb91f440950ac Mon Sep 17 00:00:00 2001 From: thenestbit <389871@edu.itmo.ru> Date: Sat, 28 Jun 2025 15:57:18 +0300 Subject: [PATCH 1/3] ready features of task mgr --- .gitignore | 1 + go.mod | 25 +++++++ go.sum | 67 ++++++++++++++++++ main.go | 25 +++++++ pkg/api/addtask.go | 68 ++++++++++++++++++ pkg/api/api.go | 57 +++++++++++++++ pkg/api/delete_task.go | 28 ++++++++ pkg/api/donetask.go | 59 ++++++++++++++++ pkg/api/edittask.go | 65 +++++++++++++++++ pkg/api/gettask.go | 24 +++++++ pkg/api/nextdate.go | 74 ++++++++++++++++++++ pkg/api/tasks.go | 39 +++++++++++ pkg/db/db.go | 92 +++++++++++++++++++++++++ pkg/db/task.go | 153 +++++++++++++++++++++++++++++++++++++++++ pkg/server/server.go | 33 +++++++++ 15 files changed, 810 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/api/addtask.go create mode 100644 pkg/api/api.go create mode 100644 pkg/api/delete_task.go create mode 100644 pkg/api/donetask.go create mode 100644 pkg/api/edittask.go create mode 100644 pkg/api/gettask.go create mode 100644 pkg/api/nextdate.go create mode 100644 pkg/api/tasks.go create mode 100644 pkg/db/db.go create mode 100644 pkg/db/task.go create mode 100644 pkg/server/server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3997bead --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.db \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..de231047 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module go_final_project + +go 1.24.4 + +require ( + github.com/jmoiron/sqlx v1.4.0 + github.com/stretchr/testify v1.10.0 + modernc.org/sqlite v1.38.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.65.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..915e636f --- /dev/null +++ b/go.sum @@ -0,0 +1,67 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= +modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= +modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= +modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 00000000..34d584b9 --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "log" + "os" + + "go_final_project/pkg/db" + "go_final_project/pkg/server" +) + +func main() { + // определяем путь к файлу БД: из окружения TODO_DBFILE или по умолчанию + dbFile := os.Getenv("TODO_DBFILE") // вернёт "" если не задано :contentReference[oaicite:10]{index=10} + if dbFile == "" { + dbFile = "scheduler.db" + } + + // инициализируем БД + if err := db.Init(dbFile); err != nil { + log.Fatalf("Database init error: %v", err) + } + + // запускаем HTTP-сервер + server.Start("web") +} diff --git a/pkg/api/addtask.go b/pkg/api/addtask.go new file mode 100644 index 00000000..745e30ed --- /dev/null +++ b/pkg/api/addtask.go @@ -0,0 +1,68 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "go_final_project/pkg/db" +) + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(v) +} + +func addTaskHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var t db.Task + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + if t.Title == "" { + writeJSON(w, map[string]string{"error": "title is required"}) + return + } + + // нормализуем дату «сегодня» к полуночи + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayStr := today.Format(isoDate) + + // если дата не указана, ставим сегодня + if t.Date == "" { + t.Date = todayStr + } else { + parsed, err := time.Parse(isoDate, t.Date) + if err != nil { + writeJSON(w, map[string]string{"error": "invalid date format"}) + return + } + // корректируем только если дата в прошлом + if parsed.Before(today) { + if t.Repeat == "" { + t.Date = todayStr + } else { + next, err := NextDate(today, t.Date, t.Repeat) + if err != nil { + writeJSON(w, map[string]string{"error": fmt.Sprintf("repeat rule error: %v", err)}) + return + } + t.Date = next + } + } + } + + id, err := db.AddTask(&t) + if err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, map[string]string{"id": fmt.Sprint(id)}) +} diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 00000000..495d2c92 --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + "time" +) + +// Init регистрирует все API-эндпоинты +func Init() { + http.HandleFunc("/api/nextdate", nextDateHandler) + http.HandleFunc("/api/task", taskHandler) + http.HandleFunc("/api/tasks", tasksHandler) + http.HandleFunc("/api/task/done", doneTaskHandler) +} + +// nextDateHandler возвращает следующую дату по правилу repeat +func nextDateHandler(w http.ResponseWriter, r *http.Request) { + nowStr := r.FormValue("now") + dstartStr := r.FormValue("date") + repeatStr := r.FormValue("repeat") + + var now time.Time + var err error + if nowStr == "" { + now = time.Now() + } else { + now, err = time.Parse(isoDate, nowStr) + if err != nil { + http.Error(w, ErrInvalidDate.Error(), http.StatusBadRequest) + return + } + } + + next, err := NextDate(now, dstartStr, repeatStr) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(next)) +} + +func taskHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + addTaskHandler(w, r) + case http.MethodGet: + getTaskHandler(w, r) + case http.MethodPut: + editTaskHandler(w, r) + case http.MethodDelete: + deleteTaskHandler(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/pkg/api/delete_task.go b/pkg/api/delete_task.go new file mode 100644 index 00000000..dfda6791 --- /dev/null +++ b/pkg/api/delete_task.go @@ -0,0 +1,28 @@ +package api + +import ( + "net/http" + + "go_final_project/pkg/db" +) + +// deleteTaskHandler обрабатывает DELETE /api/task?id= +func deleteTaskHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + writeJSON(w, map[string]string{"error": "id is required"}) + return + } + + if err := db.DeleteTask(id); err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, map[string]string{}) +} diff --git a/pkg/api/donetask.go b/pkg/api/donetask.go new file mode 100644 index 00000000..6a7b079e --- /dev/null +++ b/pkg/api/donetask.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" + "time" + + "go_final_project/pkg/db" +) + +func doneTaskHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + writeJSON(w, map[string]string{"error": "id is required"}) + return + } + + nowStr := r.URL.Query().Get("now") + var now time.Time + var err error + if nowStr == "" { + now = time.Now() + } else { + now, err = time.Parse(isoDate, nowStr) + if err != nil { + writeJSON(w, map[string]string{"error": "invalid now format"}) + return + } + } + + task, err := db.GetTask(id) + if err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + + if task.Repeat == "" { + if err := db.DeleteTask(id); err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + } else { + next, err := NextDate(now, task.Date, task.Repeat) + if err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + if err := db.UpdateDate(next, id); err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + } + + writeJSON(w, map[string]string{}) +} diff --git a/pkg/api/edittask.go b/pkg/api/edittask.go new file mode 100644 index 00000000..a03c76c9 --- /dev/null +++ b/pkg/api/edittask.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "go_final_project/pkg/db" +) + +func editTaskHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var t db.Task + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + if t.ID == "" { + writeJSON(w, map[string]string{"error": "id is required"}) + return + } + if t.Title == "" { + writeJSON(w, map[string]string{"error": "title is required"}) + return + } + + // нормализуем дату «сегодня» к полуночи + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayStr := today.Format(isoDate) + + // если дата не указана, ставим сегодня + if t.Date == "" { + t.Date = todayStr + } else { + parsed, err := time.Parse(isoDate, t.Date) + if err != nil { + writeJSON(w, map[string]string{"error": "invalid date format"}) + return + } + // корректируем только если дата в прошлом + if parsed.Before(today) { + if t.Repeat == "" { + t.Date = todayStr + } else { + next, err := NextDate(today, t.Date, t.Repeat) + if err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + t.Date = next + } + } + } + + if err := db.UpdateTask(&t); err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, map[string]string{}) +} diff --git a/pkg/api/gettask.go b/pkg/api/gettask.go new file mode 100644 index 00000000..95c34a45 --- /dev/null +++ b/pkg/api/gettask.go @@ -0,0 +1,24 @@ +package api + +import ( + "net/http" + + "go_final_project/pkg/db" +) + +// getTaskHandler возвращает JSON одной задачи по ?id= +func getTaskHandler(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + writeJSON(w, map[string]string{"error": "id is required"}) + return + } + + task, err := db.GetTask(id) + if err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, task) +} diff --git a/pkg/api/nextdate.go b/pkg/api/nextdate.go new file mode 100644 index 00000000..7138602d --- /dev/null +++ b/pkg/api/nextdate.go @@ -0,0 +1,74 @@ +package api + +import ( + "errors" + "fmt" + "time" +) + +const isoDate = "20060102" + +var ( + ErrBadRepeat = errors.New("invalid or missing repeat rule") + ErrInvalidDate = errors.New("invalid date format, expected YYYYMMDD") + ErrIntervalTooBig = errors.New("day interval exceeds maximum of 400") + ErrUnsupported = errors.New("unsupported repeat rule (only d and y)") +) + +// NextDate возвращает следующую дату > now по правилу repeat, +// считая от исходной даты dstart (формат YYYYMMDD). +func NextDate(now time.Time, dstart, repeat string) (string, error) { + // 1. Парсим dstart + start, err := time.Parse(isoDate, dstart) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidDate, err) + } + + // 2. Обработка пустого repeat + if repeat == "" { + return "", ErrBadRepeat + } + + // 3. Определяем вид регулярности + var stepFunc func(time.Time) (time.Time, error) + + switch { + case repeat == "y": + // ежегодно + stepFunc = func(t time.Time) (time.Time, error) { + return t.AddDate(1, 0, 0), nil + } + + case len(repeat) > 2 && repeat[:2] == "d ": + // d N — через N дней + var days int + if _, err := fmt.Sscanf(repeat, "d %d", &days); err != nil { + return "", ErrBadRepeat + } + if days < 1 || days > 400 { + return "", ErrIntervalTooBig + } + stepFunc = func(t time.Time) (time.Time, error) { + return t.AddDate(0, 0, days), nil + } + + default: + // остальные правила пока не поддерживаем + return "", ErrUnsupported + } + + // 4. Находим первое значение > now + candidate := start + for { + candidate, err = stepFunc(candidate) + if err != nil { + return "", err + } + // сравниваем даты без времени + if candidate.After(now) { + break + } + } + + return candidate.Format(isoDate), nil +} diff --git a/pkg/api/tasks.go b/pkg/api/tasks.go new file mode 100644 index 00000000..3088bb5b --- /dev/null +++ b/pkg/api/tasks.go @@ -0,0 +1,39 @@ +package api + +import ( + "net/http" + "strconv" + + "go_final_project/pkg/db" +) + +// TasksResp — структура JSON-ответа +type TasksResp struct { + Tasks []*db.Task `json:"tasks"` +} + +// tasksHandler обрабатывает GET /api/tasks +func tasksHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Параметры: limit (необязательно), search (необязательно) + q := r.URL.Query() + limit := 50 + if l := q.Get("limit"); l != "" { + if i, err := strconv.Atoi(l); err == nil && i > 0 { + limit = i + } + } + search := q.Get("search") + + tasks, err := db.Tasks(limit, search) + if err != nil { + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, TasksResp{Tasks: tasks}) +} diff --git a/pkg/db/db.go b/pkg/db/db.go new file mode 100644 index 00000000..9f5a48ef --- /dev/null +++ b/pkg/db/db.go @@ -0,0 +1,92 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + + // импорт драйвера без использования Cgo + + _ "modernc.org/sqlite" +) + +var DB *sql.DB + +// schema: создание таблицы и индекса, если их нет +const schema = ` +CREATE TABLE IF NOT EXISTS scheduler ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date CHAR(8) NOT NULL DEFAULT '', + title VARCHAR(255) NOT NULL, + comment TEXT, + repeat VARCHAR(128) DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_scheduler_date ON scheduler(date); +` + +// Init открывает или создаёт базу данных по пути dbFile. +// Если файла не было, выполняет схему schema. +func Init(dbFile string) error { + // проверяем существование файла + _, err := os.Stat(dbFile) // os.Stat для проверки файла + install := os.IsNotExist(err) // true, если файл отсутствует :contentReference[oaicite:5]{index=5} + + // открываем БД через database/sql + modernc.org/sqlite + db, err := sql.Open("sqlite", dbFile) // driver "sqlite" зарегистрирован импортом :contentReference[oaicite:6]{index=6} + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + if install { + // создаём таблицу и индекс + if _, err := db.Exec(schema); err != nil { + return fmt.Errorf("failed to init schema: %w", err) + } + } + + DB = db + return nil +} + +func DeleteTask(id string) error { + var idInt int64 + if _, err := fmt.Sscan(id, &idInt); err != nil { + return fmt.Errorf("invalid id format: %w", err) + } + + res, err := DB.Exec(`DELETE FROM scheduler WHERE id = ?`, idInt) + if err != nil { + return fmt.Errorf("db delete error: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected error: %w", err) + } + if n == 0 { + return fmt.Errorf("task not found") + } + return nil +} + +func UpdateDate(next, id string) error { + var idInt int64 + if _, err := fmt.Sscan(id, &idInt); err != nil { + return fmt.Errorf("invalid id format: %w", err) + } + + res, err := DB.Exec( + `UPDATE scheduler SET date = ? WHERE id = ?`, + next, idInt, + ) + if err != nil { + return fmt.Errorf("db update date error: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected error: %w", err) + } + if n == 0 { + return fmt.Errorf("task not found") + } + return nil +} diff --git a/pkg/db/task.go b/pkg/db/task.go new file mode 100644 index 00000000..ee0e6b67 --- /dev/null +++ b/pkg/db/task.go @@ -0,0 +1,153 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" +) + +// Task описывает задачу +type Task struct { + ID string `json:"id"` + Date string `json:"date"` // формат YYYYMM02 + Title string `json:"title"` // обязательное + Comment string `json:"comment"` // опционально + Repeat string `json:"repeat"` // строка правила +} + +// AddTask вставляет задачу в таблицу scheduler и возвращает её ID +func AddTask(task *Task) (int64, error) { + query := ` + INSERT INTO scheduler (date, title, comment, repeat) + VALUES (?, ?, ?, ?) + ` + res, err := DB.Exec(query, + task.Date, + task.Title, + task.Comment, + task.Repeat, + ) + if err != nil { + return 0, fmt.Errorf("db insert error: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("db last insert id error: %w", err) + } + return id, nil +} + +func Tasks(limit int, search string) ([]*Task, error) { + baseQuery := ` + SELECT id, date, title, comment, repeat + FROM scheduler + ` + var args []interface{} + where := "" + // Попытка распознать DD.MM.YYYY + if len(search) == len("02.01.2006") && strings.Count(search, ".") == 2 { + // переводим в YYYYMMDD + parts := strings.Split(search, ".") + date := parts[2] + parts[1] + parts[0] + where = " WHERE date = ?" + args = append(args, date) + } else if search != "" { + where = " WHERE title LIKE ? OR comment LIKE ?" + like := "%" + search + "%" + args = append(args, like, like) + } + + orderLimit := " ORDER BY date ASC LIMIT ?" + args = append(args, limit) + + query := baseQuery + where + orderLimit + + rows, err := DB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("db.Tasks query error: %w", err) + } + defer rows.Close() + + var tasks []*Task + for rows.Next() { + var t Task + if err := rows.Scan(&t.ID, &t.Date, &t.Title, &t.Comment, &t.Repeat); err != nil { + return nil, fmt.Errorf("db.Tasks scan error: %w", err) + } + tasks = append(tasks, &t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("db.Tasks rows error: %w", err) + } + + // чтобы JSON с empty slice был [], а не null + if tasks == nil { + tasks = make([]*Task, 0) + } + return tasks, nil +} + +func GetTask(id string) (*Task, error) { + // Сначала конвертируем строковый ID в число + var idInt int64 + if _, err := fmt.Sscan(id, &idInt); err != nil { + return nil, fmt.Errorf("invalid id format: %w", err) + } + + // SELECT по PK + query := ` + SELECT id, date, title, comment, repeat + FROM scheduler + WHERE id = ? + LIMIT 1 + ` + row := DB.QueryRow(query, idInt) + + var t Task + var idOut int64 + if err := row.Scan(&idOut, &t.Date, &t.Title, &t.Comment, &t.Repeat); err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("task not found") + } + return nil, fmt.Errorf("db scan error: %w", err) + } + // конвертируем обратно в строку + t.ID = fmt.Sprint(idOut) + return &t, nil +} + +func UpdateTask(t *Task) error { + // Конвертируем ID + var idInt int64 + if _, err := fmt.Sscan(t.ID, &idInt); err != nil { + return fmt.Errorf("invalid id format: %w", err) + } + + // Обновляем все колонки, кроме PK + query := ` + UPDATE scheduler + SET date = ?, + title = ?, + comment = ?, + repeat = ? + WHERE id = ? + ` + res, err := DB.Exec(query, + t.Date, + t.Title, + t.Comment, + t.Repeat, + idInt, + ) + if err != nil { + return fmt.Errorf("db update error: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected error: %w", err) + } + if n == 0 { + return fmt.Errorf("no task with id %s", t.ID) + } + return nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 00000000..d9c69f0f --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,33 @@ +package server + +import ( + "fmt" + "go_final_project/pkg/api" + "log" + "net/http" + "os" +) + +// Start принимает путь к директории с фронтендом и стартует HTTP-сервер. +// Если переменная окружения TODO_PORT задана, берёт порт из неё, +// иначе использует порт по умолчанию. +func Start(webDir string) { + // читаем окружение + port := os.Getenv("TODO_PORT") + if port == "" { + port = "7540" + } + + // создаём файловый сервер для отдачи статичных файлов + fs := http.FileServer(http.Dir(webDir)) + http.Handle("/", fs) + + addr := fmt.Sprintf(":%s", port) + log.Printf("Starting server on http://localhost%s/, serving %s\n", addr, webDir) + + api.Init() + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("Server failed: %v", err) + } +} From 531d6bb7f7f845f6e13e5d9a797b8402389364a7 Mon Sep 17 00:00:00 2001 From: thenestbit <389871@edu.itmo.ru> Date: Sat, 28 Jun 2025 16:04:11 +0300 Subject: [PATCH 2/3] readme update --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 597678ae..5cecea36 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,50 @@ -# Файлы для итогового задания +# Планировщик задач (TODO-сервер) -В директории `tests` находятся тесты для проверки API, которое должно быть реализовано в веб-сервере. +## Описание проекта + +Данный проект реализует веб-сервер для управления задачами (TODO-лист) с REST API и хранилищем на SQLite. Позволяет добавлять, получать, редактировать, удалять и отмечать задачи выполненными. Поддерживаются простые правила повторения (`d N`, `y`). Фронтенд на HTML/JS подключается к API для управления задачами в браузере. + +## Выполненные задания со звёздочкой + +- **Базовые правила повторения** (`d N`, `y`): реализовано. +- **Задания повышенной трудности** (недели `w`, месяцы `m`): не выполнялись. + +## Инструкция по запуску локально + +1. Клонировать репозиторий: + ```bash + git clone + cd go_final_project + ``` +2. Установить зависимости и запустить сервер: + ```bash + go mod tidy + TODO_PORT=7540 go run . + ``` +3. Перейти в браузере по адресу: `http://localhost:7540/`. + +> Опционально указать путь к БД через `TODO_DBFILE=./scheduler.db`. + +## Инструкция по запуску тестов + +В файле `tests/settings.go` задать (по умолчанию): + +```go +// для порта сервера +const Port = "7540" +// для пути к БД +const DBFile = "../scheduler.db" +// для включения расширенного тестирования NextDate +const FullNextDate = false +``` + +Запустить тесты: + +```bash +TODO_PORT=7540 TODO_DBFILE=./scheduler.db go test ./tests +``` + +## Docker + +*Не реализовано.* -Директория `web` содержит файлы фронтенда. \ No newline at end of file From 97abb2b43f1b005559a8bb2dee86491ff64a60fc Mon Sep 17 00:00:00 2001 From: thenestbit <389871@edu.itmo.ru> Date: Sun, 29 Jun 2025 14:02:48 +0300 Subject: [PATCH 3/3] Added status codes on errors --- pkg/api/addtask.go | 5 +++++ pkg/api/delete_task.go | 3 +++ pkg/api/donetask.go | 7 +++++++ pkg/api/edittask.go | 7 +++++++ pkg/api/gettask.go | 2 ++ pkg/api/tasks.go | 2 ++ 6 files changed, 26 insertions(+) diff --git a/pkg/api/addtask.go b/pkg/api/addtask.go index 745e30ed..771d8704 100644 --- a/pkg/api/addtask.go +++ b/pkg/api/addtask.go @@ -22,10 +22,12 @@ func addTaskHandler(w http.ResponseWriter, r *http.Request) { var t db.Task if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": err.Error()}) return } if t.Title == "" { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "title is required"}) return } @@ -41,6 +43,7 @@ func addTaskHandler(w http.ResponseWriter, r *http.Request) { } else { parsed, err := time.Parse(isoDate, t.Date) if err != nil { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "invalid date format"}) return } @@ -51,6 +54,7 @@ func addTaskHandler(w http.ResponseWriter, r *http.Request) { } else { next, err := NextDate(today, t.Date, t.Repeat) if err != nil { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": fmt.Sprintf("repeat rule error: %v", err)}) return } @@ -61,6 +65,7 @@ func addTaskHandler(w http.ResponseWriter, r *http.Request) { id, err := db.AddTask(&t) if err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } diff --git a/pkg/api/delete_task.go b/pkg/api/delete_task.go index dfda6791..001a1e9b 100644 --- a/pkg/api/delete_task.go +++ b/pkg/api/delete_task.go @@ -9,17 +9,20 @@ import ( // deleteTaskHandler обрабатывает DELETE /api/task?id= func deleteTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { + w.WriteHeader(http.StatusBadRequest) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } id := r.URL.Query().Get("id") if id == "" { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "id is required"}) return } if err := db.DeleteTask(id); err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } diff --git a/pkg/api/donetask.go b/pkg/api/donetask.go index 6a7b079e..22c5184b 100644 --- a/pkg/api/donetask.go +++ b/pkg/api/donetask.go @@ -9,12 +9,14 @@ import ( func doneTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { + w.WriteHeader(http.StatusBadRequest) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } id := r.URL.Query().Get("id") if id == "" { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "id is required"}) return } @@ -27,6 +29,7 @@ func doneTaskHandler(w http.ResponseWriter, r *http.Request) { } else { now, err = time.Parse(isoDate, nowStr) if err != nil { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "invalid now format"}) return } @@ -34,22 +37,26 @@ func doneTaskHandler(w http.ResponseWriter, r *http.Request) { task, err := db.GetTask(id) if err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } if task.Repeat == "" { if err := db.DeleteTask(id); err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } } else { next, err := NextDate(now, task.Date, task.Repeat) if err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } if err := db.UpdateDate(next, id); err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } diff --git a/pkg/api/edittask.go b/pkg/api/edittask.go index a03c76c9..d94b2844 100644 --- a/pkg/api/edittask.go +++ b/pkg/api/edittask.go @@ -10,20 +10,24 @@ import ( func editTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { + w.WriteHeader(http.StatusBadRequest) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var t db.Task if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": err.Error()}) return } if t.ID == "" { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "id is required"}) return } if t.Title == "" { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "title is required"}) return } @@ -39,6 +43,7 @@ func editTaskHandler(w http.ResponseWriter, r *http.Request) { } else { parsed, err := time.Parse(isoDate, t.Date) if err != nil { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "invalid date format"}) return } @@ -49,6 +54,7 @@ func editTaskHandler(w http.ResponseWriter, r *http.Request) { } else { next, err := NextDate(today, t.Date, t.Repeat) if err != nil { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": err.Error()}) return } @@ -58,6 +64,7 @@ func editTaskHandler(w http.ResponseWriter, r *http.Request) { } if err := db.UpdateTask(&t); err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } diff --git a/pkg/api/gettask.go b/pkg/api/gettask.go index 95c34a45..b6d41e66 100644 --- a/pkg/api/gettask.go +++ b/pkg/api/gettask.go @@ -10,12 +10,14 @@ import ( func getTaskHandler(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { + w.WriteHeader(http.StatusBadRequest) writeJSON(w, map[string]string{"error": "id is required"}) return } task, err := db.GetTask(id) if err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return } diff --git a/pkg/api/tasks.go b/pkg/api/tasks.go index 3088bb5b..5f803864 100644 --- a/pkg/api/tasks.go +++ b/pkg/api/tasks.go @@ -15,6 +15,7 @@ type TasksResp struct { // tasksHandler обрабатывает GET /api/tasks func tasksHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { + w.WriteHeader(http.StatusBadRequest) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } @@ -31,6 +32,7 @@ func tasksHandler(w http.ResponseWriter, r *http.Request) { tasks, err := db.Tasks(limit, search) if err != nil { + w.WriteHeader(http.StatusInternalServerError) writeJSON(w, map[string]string{"error": err.Error()}) return }