From 5e160ae11c575f2331fd0c7bd0bf26139c415b9d Mon Sep 17 00:00:00 2001
From: Antoine Pelletier
Date: Tue, 29 Sep 2026 09:47:27 +0200
Subject: [PATCH] wip
---
Cargo.lock | 426 +++++++++++++++-
Cargo.toml | 4 +
README.md | 3 +-
config.example.yml | 12 +-
.../20260929180000_associations_focus.sql | 41 ++
db/schema.sql | 29 +-
frontend/src/components/AppContent.vue | 19 +-
...{CoursesCalendar.vue => TasksCalendar.vue} | 48 +-
frontend/src/components/calendar/TodoList.vue | 185 +++++++
.../course/AssociationDeadlinesTab.vue | 190 ++++++++
.../src/components/course/CourseFilesTab.vue | 23 +-
.../src/components/course/CourseTasksTab.vue | 58 ++-
.../src/components/course/TaskFormDialog.vue | 71 ++-
.../components/courses/CourseFormDialog.vue | 35 +-
.../components/schedule/SlotFormDialog.vue | 46 +-
.../components/timetable/WeekTimetable.vue | 39 +-
.../ui/alert-dialog/AlertDialogContent.vue | 2 +-
.../components/ui/dialog/DialogContent.vue | 2 +-
frontend/src/lib/api.d.ts | 274 ++++++++++-
frontend/src/locales/en.yml | 89 +++-
frontend/src/locales/fr.yml | 89 +++-
frontend/src/router/index.ts | 57 ++-
frontend/src/router/navigation.ts | 8 +-
frontend/src/services/api/course-files.ts | 9 +-
frontend/src/services/api/courses.ts | 12 +-
frontend/src/services/api/tasks.ts | 95 +++-
frontend/src/services/focus.ts | 204 ++++++++
frontend/src/utils/calendar.ts | 2 +-
frontend/src/utils/routes.ts | 43 ++
frontend/src/utils/types.ts | 6 +
frontend/src/views/CalendarView.vue | 132 +++--
frontend/src/views/CourseView.vue | 59 ++-
frontend/src/views/CoursesView.vue | 48 +-
frontend/src/views/HomeView.vue | 2 +-
frontend/src/views/LectureNoteView.vue | 18 +-
frontend/src/views/PomodoroView.vue | 458 +++++++++++++++++-
src/api/helpers.rs | 1 +
src/api/models.rs | 20 +-
src/api/tasks.rs | 77 ++-
src/core/controller/course_files.rs | 99 +++-
src/core/controller/courses.rs | 147 +++++-
src/core/controller/focus.rs | 183 +++++++
src/core/controller/mod.rs | 10 +-
src/core/controller/tasks.rs | 30 +-
src/core/models/course.rs | 46 +-
src/core/models/course_file.rs | 5 +-
src/core/models/focus.rs | 45 ++
src/core/models/mod.rs | 1 +
src/core/models/tag.rs | 2 +
src/core/models/task.rs | 16 +-
.../repositories/course_files_repository.rs | 6 +-
src/core/repositories/courses_repository.rs | 13 +-
src/core/repositories/mod.rs | 1 +
src/core/repositories/storage_repository.rs | 3 +
src/core/repositories/tasks_repository.rs | 6 +
src/core/repositories/time_tracker.rs | 21 +
src/main.rs | 20 +-
src/services/database/course_files.rs | 14 +-
src/services/database/courses.rs | 57 ++-
src/services/database/tags.rs | 4 +-
src/services/database/tasks.rs | 42 +-
src/services/mod.rs | 1 +
src/services/storage.rs | 26 +
src/services/timetagger.rs | 135 ++++++
src/utils/config.rs | 15 +-
65 files changed, 3569 insertions(+), 315 deletions(-)
create mode 100644 db/migrations/20260929180000_associations_focus.sql
rename frontend/src/components/calendar/{CoursesCalendar.vue => TasksCalendar.vue} (82%)
create mode 100644 frontend/src/components/calendar/TodoList.vue
create mode 100644 frontend/src/components/course/AssociationDeadlinesTab.vue
create mode 100644 frontend/src/services/focus.ts
create mode 100644 frontend/src/utils/routes.ts
create mode 100644 src/core/controller/focus.rs
create mode 100644 src/core/models/focus.rs
create mode 100644 src/core/repositories/time_tracker.rs
create mode 100644 src/services/timetagger.rs
diff --git a/Cargo.lock b/Cargo.lock
index 064b86b..c500dec 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -226,6 +226,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "chrono"
version = "0.4.45"
@@ -310,6 +327,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc"
version = "3.4.0"
@@ -678,8 +704,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -689,8 +717,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -851,6 +882,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
]
[[package]]
@@ -859,13 +907,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
+ "base64",
"bytes",
+ "futures-channel",
+ "futures-util",
"http",
"http-body",
"hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
"pin-project-lite",
+ "socket2",
"tokio",
"tower-service",
+ "tracing",
]
[[package]]
@@ -1013,6 +1069,12 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1111,6 +1173,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -1189,7 +1257,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
- "rand",
+ "rand 0.8.7",
"smallvec",
"zeroize",
]
@@ -1397,6 +1465,62 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.3",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -1420,7 +1544,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha",
- "rand_core",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
]
[[package]]
@@ -1430,7 +1565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
@@ -1442,6 +1577,21 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1497,6 +1647,58 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "ron"
version = "0.12.2"
@@ -1524,7 +1726,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
- "rand_core",
+ "rand_core 0.6.4",
"signature",
"spki",
"subtle",
@@ -1541,6 +1743,12 @@ dependencies = [
"ordered-multimap",
]
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
[[package]]
name = "rustix"
version = "1.1.5"
@@ -1554,6 +1762,41 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -1717,7 +1960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
@@ -1728,7 +1971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
@@ -1764,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
@@ -1927,7 +2170,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
- "rand",
+ "rand 0.8.7",
"rsa",
"serde",
"sha1",
@@ -1966,7 +2209,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
- "rand",
+ "rand 0.8.7",
"serde",
"serde_json",
"sha2",
@@ -2035,6 +2278,7 @@ dependencies = [
"axum",
"chrono",
"config",
+ "reqwest",
"schemars",
"serde",
"serde_json",
@@ -2080,6 +2324,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
[[package]]
name = "synstructure"
@@ -2195,6 +2442,16 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.26.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -2287,8 +2544,10 @@ dependencies = [
"pin-project-lite",
"tokio",
"tokio-util",
+ "tower",
"tower-layer",
"tower-service",
+ "url",
]
[[package]]
@@ -2365,6 +2624,12 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -2422,6 +2687,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
[[package]]
name = "url"
version = "2.5.8"
@@ -2458,6 +2729,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -2483,6 +2763,16 @@ dependencies = [
"wasm-bindgen-shared",
]
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
@@ -2515,6 +2805,35 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "whoami"
version = "1.6.1"
@@ -2590,7 +2909,16 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
- "windows-targets",
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
]
[[package]]
@@ -2608,13 +2936,29 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
]
[[package]]
@@ -2623,42 +2967,90 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
[[package]]
name = "winnow"
version = "1.0.4"
diff --git a/Cargo.toml b/Cargo.toml
index 89732ad..83f9fa8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,10 @@ axum = { version = "0.8.9", features = ["macros"] }
chrono = { version = "0.4.45", features = ["serde"] }
config = "0.15.23"
schemars = { version = "0.9.0", features = ["chrono04"] }
+reqwest = { version = "0.12.28", default-features = false, features = [
+ "json",
+ "rustls-tls",
+] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = [
diff --git a/README.md b/README.md
index 1b01aa6..e9e33cd 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,8 @@ key, keep it up to date. Every value can also come from the environment
│ ├── services/ implementations of the repositories (postgres/sqlx)
│ └── utils/ configuration
├── LaTeX_pdf/ template of the lecture notes pdf (pandoc + pdflatex)
-├── storage/ files of the courses, stands in for the NAS (gitignored)
+├── storage/ files of the courses and associations, one folder each,
+│ stands in for the NAS (gitignored)
├── db/
│ ├── migrations/ dbmate migrations
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
diff --git a/config.example.yml b/config.example.yml
index b41c677..006f100 100644
--- a/config.example.yml
+++ b/config.example.yml
@@ -14,7 +14,8 @@ postgres:
# Directory containing the built frontend (frontend/dist after `npm run build`)
frontend_dir: frontend/dist
-# Where the files of the courses are stored (lecture notes and their pdf...).
+# Where the files of the courses and associations are stored (lecture notes and their
+# pdf...), one folder each: Cours/CS-477 Advanced OS/Notes/..., Associations/...
# A local directory standing in for the NAS for now.
storage_dir: storage
@@ -27,3 +28,12 @@ notes:
# Shown in the header of the pdf, both optional
student_name: Jane Doe
student_id: "123456"
+
+# Optional: the focus sessions of the pomodoro are also counted in TimeTagger, tagged
+# with the course code, the tags and the name of the task. Without it, the time is only
+# counted in the app.
+# timetagger:
+# # https://timetagger.app/api/v2 by default, or the api of your own server
+# url: https://timetagger.app/api/v2
+# # Api token, from the account page of TimeTagger
+# token: ...
diff --git a/db/migrations/20260929180000_associations_focus.sql b/db/migrations/20260929180000_associations_focus.sql
new file mode 100644
index 0000000..4727d7e
--- /dev/null
+++ b/db/migrations/20260929180000_associations_focus.sql
@@ -0,0 +1,41 @@
+-- migrate:up
+
+-- Associations are managed like courses: same tasks, files and slots, without grades.
+ALTER TABLE courses ADD COLUMN kind TEXT NOT NULL DEFAULT 'course'
+ CHECK (kind IN ('course', 'association'));
+ALTER TABLE courses ALTER COLUMN kind DROP DEFAULT;
+
+-- Folder of the course in the storage (the NAS), relative to its root, named after the
+-- course: `Cours/CS-477 Advanced operating systems`. Moved when the course is renamed.
+-- The existing folders were named after the ids and are not moved by this migration.
+ALTER TABLE courses ADD COLUMN folder TEXT;
+UPDATE courses SET folder = 'Cours/' || trim(regexp_replace(
+ regexp_replace(COALESCE(code || ' ', '') || "name", '[/\\:*?"<>|]', '-', 'g'),
+ '\s+', ' ', 'g'));
+ALTER TABLE courses ALTER COLUMN folder SET NOT NULL;
+ALTER TABLE courses ADD CONSTRAINT courses_folder_key UNIQUE (folder);
+
+-- Path of a file in the folder of its course, without extension: `Notes/Lecture 1 Notes`
+-- (the note is `Notes/Lecture 1 Notes.md` and its pdf `Notes/Lecture 1 Notes.pdf`)
+ALTER TABLE course_files ADD COLUMN "path" TEXT;
+UPDATE course_files SET "path" = 'Notes/' || trim(regexp_replace(
+ regexp_replace("name", '[/\\:*?"<>|]', '-', 'g'), '\s+', ' ', 'g'));
+ALTER TABLE course_files ALTER COLUMN "path" SET NOT NULL;
+ALTER TABLE course_files ADD CONSTRAINT course_files_course_id_path_key UNIQUE (course_id, "path");
+
+-- Time spent working on a task, counted by the pomodoro
+ALTER TABLE tasks ADD COLUMN time_spent INTEGER NOT NULL DEFAULT 0 CHECK (time_spent >= 0);
+
+-- Kinds of the slots and tasks of the associations
+INSERT INTO tags ("name", color, scopes, slug) VALUES
+ ('{"fr": "Réunion", "en": "Meeting"}', '#db2777', '{slot,task}', 'meeting'),
+ ('{"fr": "Événement", "en": "Event"}', '#4f46e5', '{slot,task}', 'event');
+
+-- migrate:down
+DELETE FROM courses WHERE kind = 'association';
+DELETE FROM time_slots WHERE tag_id IN (SELECT id FROM tags WHERE slug IN ('meeting', 'event'));
+DELETE FROM tags WHERE slug IN ('meeting', 'event');
+ALTER TABLE tasks DROP COLUMN time_spent;
+ALTER TABLE course_files DROP COLUMN "path";
+ALTER TABLE courses DROP COLUMN folder;
+ALTER TABLE courses DROP COLUMN kind;
diff --git a/db/schema.sql b/db/schema.sql
index 19ce074..4a2d0fa 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -31,6 +31,7 @@ CREATE TABLE public.course_files (
created_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_created_at_not_null NOT NULL,
updated_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_updated_at_not_null NOT NULL,
kind text NOT NULL,
+ path text NOT NULL,
CONSTRAINT course_files_kind_check CHECK ((kind = ANY (ARRAY['note'::text, 'upload'::text])))
);
@@ -64,7 +65,10 @@ CREATE TABLE public.courses (
name text NOT NULL,
code text,
teachers text[] DEFAULT '{}'::text[] NOT NULL,
- main_page_url text
+ main_page_url text,
+ kind text NOT NULL,
+ folder text NOT NULL,
+ CONSTRAINT courses_kind_check CHECK ((kind = ANY (ARRAY['course'::text, 'association'::text])))
);
@@ -201,7 +205,9 @@ CREATE TABLE public.tasks (
description text,
links text[] DEFAULT '{}'::text[] NOT NULL,
deadline timestamp with time zone,
- done boolean DEFAULT false NOT NULL
+ done boolean DEFAULT false NOT NULL,
+ time_spent integer DEFAULT 0 NOT NULL,
+ CONSTRAINT tasks_time_spent_check CHECK ((time_spent >= 0))
);
@@ -303,6 +309,14 @@ ALTER TABLE ONLY public.tasks ALTER COLUMN id SET DEFAULT nextval('public.tasks_
ALTER TABLE ONLY public.time_slots ALTER COLUMN id SET DEFAULT nextval('public.time_slots_id_seq'::regclass);
+--
+-- Name: course_files course_files_course_id_path_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.course_files
+ ADD CONSTRAINT course_files_course_id_path_key UNIQUE (course_id, path);
+
+
--
-- Name: course_files course_files_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -311,6 +325,14 @@ ALTER TABLE ONLY public.course_files
ADD CONSTRAINT course_files_pkey PRIMARY KEY (id);
+--
+-- Name: courses courses_folder_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.courses
+ ADD CONSTRAINT courses_folder_key UNIQUE (folder);
+
+
--
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -505,4 +527,5 @@ ALTER TABLE ONLY public.time_slots
INSERT INTO public.schema_migrations (version) VALUES
('20260928120000'),
('20260928150000'),
- ('20260929090000');
+ ('20260929090000'),
+ ('20260929180000');
diff --git a/frontend/src/components/AppContent.vue b/frontend/src/components/AppContent.vue
index 926e0c1..857b963 100644
--- a/frontend/src/components/AppContent.vue
+++ b/frontend/src/components/AppContent.vue
@@ -12,11 +12,14 @@ import {
import { Separator } from '@/components/ui/separator'
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
import ThemeToggle from '@/components/ThemeToggle.vue'
+import { formatDuration, useFocus } from '@/services/focus'
import { locales, setLocale } from '@/services/i18n'
import { useI18n } from 'vue-i18n'
+import { Pause, Timer } from '@lucide/vue'
const route = useRoute()
const { locale: currentLocale } = useI18n()
+const { session, elapsed, isRunning } = useFocus()
const selectedLocale = computed(
() => locales.find((l) => l.lang === currentLocale.value) ?? locales[0],
@@ -30,7 +33,21 @@ const selectedLocale = computed(
{{ route.meta.title ? $t(route.meta.title) : '' }}
-
+
+
+
+
+ {{ formatDuration(elapsed) }}
+
+ {{ session.task?.name ?? $t('focus.free') }}
+
+
+
-
+
{{ selectedLabel }}
+
- -
+
-
+
diff --git a/frontend/src/components/calendar/TodoList.vue b/frontend/src/components/calendar/TodoList.vue
new file mode 100644
index 0000000..c0da214
--- /dev/null
+++ b/frontend/src/components/calendar/TodoList.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('tasks.load-error') }}
+
+
+
+
+
+ {{ $t('calendar.todo.empty') }}
+
+
+
+
+ {{ $t(`calendar.todo.${group}`) }}
+ · {{ todos.length }}
+
+
+ -
+
+
+
+
+ {{ task.name }}
+
+
+ {{ course.code ?? course.name }}
+
+ ·
+
+ {{ formatRelative(task.deadline, locale) }}
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ {{ subtask.name }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/course/AssociationDeadlinesTab.vue b/frontend/src/components/course/AssociationDeadlinesTab.vue
new file mode 100644
index 0000000..b0b09f3
--- /dev/null
+++ b/frontend/src/components/course/AssociationDeadlinesTab.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
{{ weekLabel }}
+
+
+
+
+ {{ $t('association.deadlines.hint') }}
+
+
+
+
+
+ {{ time(task) }}
+ {{ task.name }}
+
+
+
+
+
+ {{ $t('association.deadlines.weekend') }}
+
+ {{ task.name }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/course/CourseFilesTab.vue b/frontend/src/components/course/CourseFilesTab.vue
index a57363d..755965a 100644
--- a/frontend/src/components/course/CourseFilesTab.vue
+++ b/frontend/src/components/course/CourseFilesTab.vue
@@ -18,14 +18,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useCourseFiles, useLectureNoteMutations } from '@/services/api/course-files'
import { useTags } from '@/services/api/tags'
import { formatDate } from '@/utils/dates'
-import type { CourseFile } from '@/utils/types'
+import { noteRoute } from '@/utils/routes'
+import type { Course, CourseFile } from '@/utils/types'
-const props = defineProps<{ courseId: number }>()
+const props = defineProps<{ course: Course }>()
const { t, locale } = useI18n()
const { tagsById } = useTags()
-const { data: files, isPending, isError, refetch } = useCourseFiles(() => props.courseId)
-const { remove } = useLectureNoteMutations(() => props.courseId)
+const { data: files, isPending, isError, refetch } = useCourseFiles(() => props.course.id)
+const { remove } = useLectureNoteMutations(() => props.course.id)
const deleteOpen = ref(false)
const deletedFile = ref
()
@@ -50,7 +51,7 @@ async function confirmDelete() {
- {{ $t('files.title') }}
+ {{ $t(`files.title.${course.kind}`) }}
{{ $t('files.description') }}
@@ -67,7 +68,7 @@ async function confirmDelete() {
{{ $t('files.coming-soon') }}
@@ -132,15 +144,15 @@ async function confirmDelete() {
-
+
- {{ $t('courses.delete-title', { name: deletedCourse?.name }) }}
+ {{ $t(`${k}.delete-title`, { name: deletedCourse?.name }) }}
- {{ $t('courses.delete-description') }}
+ {{ $t(`${k}.delete-description`) }}
{{ $t('common.cancel') }}
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue
index 650cb48..68259a2 100644
--- a/frontend/src/views/HomeView.vue
+++ b/frontend/src/views/HomeView.vue
@@ -10,7 +10,7 @@ import { sections } from '@/router/navigation'
{{ $t('home.subtitle') }}
-
+
()
@@ -78,11 +79,8 @@ watch(
{ immediate: true },
)
-const backTo = computed(() => ({
- name: 'course',
- params: { id: props.id },
- query: { tab: 'files' },
-}))
+const kind = computed(() => (route.name === 'association-note' ? 'association' : 'course'))
+const backTo = computed(() => coursePageRoute({ id: props.id, kind: kind.value }, 'files'))
async function submit() {
if (!title.value.trim()) {
@@ -97,11 +95,9 @@ async function submit() {
saved.value = { title: title.value, content: content.value }
pdfError.value = result.pdf_error ?? undefined
if (isNew.value) {
- await router.replace({
- name: 'course-note',
- params: { id: props.id, noteId: result.note.id },
- query: result.pdf_error ? { edit: '1' } : {},
- })
+ await router.replace(
+ noteRoute({ id: props.id, kind: kind.value }, result.note.id, !!result.pdf_error),
+ )
}
if (result.pdf_error) {
toast.warning(t('notes.saved-without-pdf'))
@@ -153,7 +149,7 @@ function pdfUrl(note: CourseFile) {
- {{ course?.name ?? $t('course.back') }}
+ {{ course?.name ?? $t(`${kind}.back`) }}
diff --git a/frontend/src/views/PomodoroView.vue b/frontend/src/views/PomodoroView.vue
index 5125c92..ccff3fd 100644
--- a/frontend/src/views/PomodoroView.vue
+++ b/frontend/src/views/PomodoroView.vue
@@ -1,7 +1,461 @@
-
+
+
+
+
{{ today }}
+
+
+
+ {{ $t('focus.resources') }}
+
+
+
+
+ {{ $t('focus.fullscreen') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ task.course.code }} ·
+ {{ task.course.name }}
+
+
+
+ {{ task.parent_name }} ›
+
+ {{ task.name }}
+
+
+
+
+
+
+
+
{{ $t('focus.free') }}
+
+
+
+
+
+
+
+ {{ formatDuration(elapsed) }}
+
+ {{ clock }}
+
+
+ +{{ cycles - 8 }}
+
+
+
+
+
+ {{ $t('focus.paused', { time: formatDuration(pauseElapsed) }) }}
+ · {{ $t('focus.suggested-break', { time: formatSpent(suggestedBreak) }) }}
+
+
+
+
+
+
+
+ {{ isRunning ? $t('focus.pause') : $t('focus.resume') }}
+
+
+
+ {{ $t('focus.stop') }}
+
+
+
+
+ {{ $t('focus.total', { time: formatSpent(totalSpent) }) }}
+
+
+ TimeTagger
+
+ {{ $t('focus.space-hint') }}
+
+
+
+
+
+
+
+ {{ clock }}
+
+
{{ $t('focus.pick') }}
+
+
+
+
+
+
+
+ -
+ focus.start(choice.task.id))"
+ >
+
+
+
+
+ {{ choice.parent }} ›
+
+ {{ choice.task.name }}
+
+
+ {{ choice.course.code ?? choice.course.name }}
+
+ · {{ formatRelative(choice.task.deadline, locale) }}
+
+
+
+
+
+ -
+ {{ $t('focus.no-task') }}
+
+
+
focus.start(null))">
+
+ {{ $t('focus.start-free') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/api/helpers.rs b/src/api/helpers.rs
index c19c832..4237c34 100644
--- a/src/api/helpers.rs
+++ b/src/api/helpers.rs
@@ -21,6 +21,7 @@ pub fn error_response(fn_name: &str, err: ControllerError) -> Result Some(err.to_string()),
ControllerError::Task(err) => Some(err.to_string()),
ControllerError::LectureNote(err) => Some(err.to_string()),
+ ControllerError::Focus(err) => Some(err.to_string()),
_ => None,
};
match (invalid, err) {
diff --git a/src/api/models.rs b/src/api/models.rs
index 0482724..97911f2 100644
--- a/src/api/models.rs
+++ b/src/api/models.rs
@@ -6,8 +6,9 @@ use schemars::JsonSchema;
use serde::Deserialize;
use crate::core::models::{
- course::{CourseData, CourseId},
+ course::{CourseData, CourseId, CourseKind},
course_file::CourseFileId,
+ focus::TimetaggerRecord,
grade::{GradeData, GradeId},
tag::TagId,
task::{TaskData, TaskId},
@@ -48,12 +49,15 @@ pub struct PeriodQuery {
pub to: DateTime,
}
-/// A course, without its slots: they are added and removed one by one
+/// A course or an association, without its slots: they are added and removed one by one
#[derive(Deserialize, JsonSchema)]
pub struct CourseBody {
+ /// `course` by default. Cannot be changed once created.
+ pub kind: Option,
pub name: String,
pub code: Option,
- /// At least one
+ /// At least one for a course
+ #[serde(default)]
pub teachers: Vec,
/// http(s) url of the main page of the course
pub main_page_url: Option,
@@ -104,6 +108,15 @@ pub struct CreateTaskBody {
pub task: TaskBody,
}
+/// End (or pause) of a focus session
+#[derive(Deserialize, JsonSchema)]
+pub struct FocusStopBody {
+ /// Length of the session, at most 24 hours
+ pub seconds: u32,
+ /// Record returned when the session started, to stop it
+ pub timetagger: Option,
+}
+
#[derive(Deserialize, JsonSchema)]
pub struct LectureNoteBody {
/// Subtitle of the pdf, e.g. "Lecture 2 Notes: Memory subsystem"
@@ -115,6 +128,7 @@ pub struct LectureNoteBody {
impl From for CourseData {
fn from(value: CourseBody) -> Self {
CourseData {
+ kind: value.kind.unwrap_or(CourseKind::Course),
name: value.name,
code: value.code,
teachers: value.teachers,
diff --git a/src/api/tasks.rs b/src/api/tasks.rs
index 3ab0619..2144cf5 100644
--- a/src/api/tasks.rs
+++ b/src/api/tasks.rs
@@ -1,10 +1,12 @@
//! Tasks of a course. Every change returns all the tasks of the course, nested and
//! sorted, with the deadlines: the page never needs a second request.
+//!
+//! The focus sessions of the pomodoro are counted on the tasks, and in TimeTagger.
use aide::{
axum::{
ApiRouter,
- routing::{get_with, put_with},
+ routing::{get_with, post_with, put_with},
},
transform::TransformOperation,
};
@@ -17,11 +19,14 @@ use axum::{
use crate::{
api::{
helpers::{desc, error_response},
- models::{CoursePath, CreateTaskBody, PeriodQuery, TaskBody, TaskPath},
+ models::{CoursePath, CreateTaskBody, FocusStopBody, PeriodQuery, TaskBody, TaskPath},
},
core::{
controller::AppController,
- models::task::{CalendarTask, CourseTasks},
+ models::{
+ focus::{FocusStart, FocusStop},
+ task::{CalendarTask, CourseTasks, TodoTask},
+ },
},
};
@@ -31,6 +36,10 @@ pub fn routes() -> ApiRouter {
"/api/tasks",
get_with(get_calendar_tasks, get_calendar_tasks_docs),
)
+ .api_route(
+ "/api/tasks/todo",
+ get_with(get_todo_tasks, get_todo_tasks_docs),
+ )
.api_route(
"/api/courses/{id}/tasks",
get_with(get_course_tasks, get_course_tasks_docs)
@@ -40,6 +49,68 @@ pub fn routes() -> ApiRouter {
"/api/tasks/{id}",
put_with(update_task, update_task_docs).delete_with(delete_task, delete_task_docs),
)
+ .api_route(
+ "/api/tasks/{id}/focus",
+ post_with(start_focus, start_focus_docs),
+ )
+ .api_route(
+ "/api/tasks/{id}/focus/stop",
+ post_with(stop_focus, stop_focus_docs),
+ )
+}
+
+async fn get_todo_tasks(
+ controller: AppController,
+) -> Result>, (StatusCode, String)> {
+ match controller.get_todo_tasks().await {
+ Ok(tasks) => Ok(Json(tasks)),
+ Err(err) => error_response("get_todo_tasks", err),
+ }
+}
+
+fn get_todo_tasks_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks").summary(
+ "Get the tasks not done of every course and association, with their subtasks, \
+ the closest deadline first",
+ )
+}
+
+async fn start_focus(
+ controller: AppController,
+ Path(path): Path,
+) -> Result, (StatusCode, String)> {
+ match controller.start_focus(path.id).await {
+ Ok(focus) => Ok(Json(focus)),
+ Err(err) => error_response("start_focus", err),
+ }
+}
+
+fn start_focus_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks")
+ .summary("Start a focus session on a task, and its TimeTagger record")
+ .description("The session starts even when TimeTagger fails: see `timetagger_error`.")
+ .response_with::<404, String, _>(desc("The task does not exist"))
+}
+
+async fn stop_focus(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> Result, (StatusCode, String)> {
+ match controller
+ .stop_focus(path.id, body.seconds, body.timetagger)
+ .await
+ {
+ Ok(focus) => Ok(Json(focus)),
+ Err(err) => error_response("stop_focus", err),
+ }
+}
+
+fn stop_focus_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks")
+ .summary("End or pause a focus session: its time is added to the task")
+ .response_with::<400, String, _>(desc("The session is too long"))
+ .response_with::<404, String, _>(desc("The task does not exist"))
}
async fn get_calendar_tasks(
diff --git a/src/core/controller/course_files.rs b/src/core/controller/course_files.rs
index 722e947..783a3ea 100644
--- a/src/core/controller/course_files.rs
+++ b/src/core/controller/course_files.rs
@@ -1,16 +1,17 @@
//! Files of a course. For now only the lecture notes: markdown written in the app,
//! rendered to pdf with the course template, and tagged `notes`.
//!
-//! Both files of a note live in the storage, next to the other files of the course:
-//! `courses/{course}/notes/{note}.md` and `.pdf`. The `.md` holds the front matter
-//! expected by the template, so it can also be rendered by hand with `mdpdf`. The
-//! editor only sees what comes after it.
+//! Both files of a note live in the folder of the course, named after the note:
+//! `Cours/CS-477 Advanced OS/Notes/Lecture 1 Notes - Intro.md` and `.pdf`. They are
+//! moved when the note is renamed. The `.md` holds the front matter expected by the
+//! template, so it can also be rendered by hand with `mdpdf`. The editor only sees what
+//! comes after it.
use thiserror::Error;
use tracing::error;
use crate::core::{
- controller::{ControllerError, courses::course_dir},
+ controller::{ControllerError, courses::file_name},
models::{
course::{Course, CourseId},
course_file::{CourseFile, CourseFileId, FileKind, LectureNoteContent, SavedLectureNote},
@@ -28,12 +29,20 @@ pub struct NotesSettings {
pub logo: Option,
}
-fn markdown_path(note: &CourseFile) -> String {
- format!("{}/notes/{}.md", course_dir(note.course_id), note.id)
+/// Sub folder of the course holding the notes
+const NOTES_FOLDER: &str = "Notes";
+
+fn markdown_path(course: &Course, path: &str) -> String {
+ format!("{}/{path}.md", course.folder)
}
-fn pdf_path(note: &CourseFile) -> String {
- format!("{}/notes/{}.pdf", course_dir(note.course_id), note.id)
+fn pdf_path(course: &Course, path: &str) -> String {
+ format!("{}/{path}.pdf", course.folder)
+}
+
+/// Path of a note in the folder of its course, without extension
+fn note_path(title: &str) -> String {
+ format!("{NOTES_FOLDER}/{}", file_name(title))
}
impl super::AppController {
@@ -63,7 +72,12 @@ impl super::AppController {
id: CourseFileId,
) -> Result {
let note = self.get_note(id).await?;
- let content = match self.storage.read_file(&markdown_path(¬e)).await {
+ let course = self.db.get_course(note.course_id).await?;
+ let content = match self
+ .storage
+ .read_file(&markdown_path(&course, ¬e.path))
+ .await
+ {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
// Lost file: the note can still be rewritten
Err(RepositoryError::NotFound(_)) => String::new(),
@@ -78,8 +92,9 @@ impl super::AppController {
/// `NotFound` if the note does not exist or its last rendering failed
pub async fn get_lecture_note_pdf(&self, id: CourseFileId) -> Result, ControllerError> {
let note = self.get_note(id).await?;
+ let course = self.db.get_course(note.course_id).await?;
self.storage
- .read_file(&pdf_path(¬e))
+ .read_file(&pdf_path(&course, ¬e.path))
.await
.map_err(Into::into)
}
@@ -92,10 +107,12 @@ impl super::AppController {
) -> Result {
let title = validate_title(title)?;
let course = self.db.get_course(course_id).await?;
+ let path = note_path(&title);
+ self.check_path_free(course_id, &path, None).await?;
let notes_tag = self.db.get_tag_id(NOTES_TAG).await?;
let note = self
.db
- .create_lecture_note(course_id, title.clone(), &[notes_tag])
+ .create_lecture_note(course_id, title.clone(), path, &[notes_tag])
.await?;
self.save_lecture_note(&course, note, title, content).await
}
@@ -109,14 +126,55 @@ impl super::AppController {
let title = validate_title(title)?;
let note = self.get_note(id).await?;
let course = self.db.get_course(note.course_id).await?;
+
+ // Renamed: its files follow
+ let path = note_path(&title);
+ if path != note.path {
+ self.check_path_free(course.id, &path, Some(id)).await?;
+ for (from, to) in [
+ (
+ markdown_path(&course, ¬e.path),
+ markdown_path(&course, &path),
+ ),
+ (pdf_path(&course, ¬e.path), pdf_path(&course, &path)),
+ ] {
+ match self.storage.rename(&from, &to).await {
+ Ok(()) | Err(RepositoryError::NotFound(_)) => {}
+ Err(err) => return Err(err.into()),
+ }
+ }
+ }
+ let note = CourseFile { path, ..note };
self.save_lecture_note(&course, note, title, content).await
}
pub async fn delete_lecture_note(&self, id: CourseFileId) -> Result<(), ControllerError> {
let note = self.get_note(id).await?;
+ let course = self.db.get_course(note.course_id).await?;
self.db.delete_course_file(id).await?;
- self.storage.delete_file(&markdown_path(¬e)).await?;
- self.storage.delete_file(&pdf_path(¬e)).await?;
+ self.storage
+ .delete_file(&markdown_path(&course, ¬e.path))
+ .await?;
+ self.storage
+ .delete_file(&pdf_path(&course, ¬e.path))
+ .await?;
+ Ok(())
+ }
+
+ /// `NameTaken` if another file of the course (than `except`) already has this path
+ async fn check_path_free(
+ &self,
+ course_id: CourseId,
+ path: &str,
+ except: Option,
+ ) -> Result<(), ControllerError> {
+ let files = self.db.get_course_files(course_id).await?;
+ if files
+ .iter()
+ .any(|file| file.path == path && Some(file.id) != except)
+ {
+ return Err(LectureNotesControllerError::NameTaken.into());
+ }
Ok(())
}
@@ -130,18 +188,19 @@ impl super::AppController {
content: String,
) -> Result {
let markdown = format!("{}{}", self.front_matter(course, &title), content);
+ let pdf = pdf_path(course, ¬e.path);
self.storage
- .write_file(&markdown_path(¬e), markdown.as_bytes())
+ .write_file(&markdown_path(course, ¬e.path), markdown.as_bytes())
.await?;
let pdf_error = match self.renderer.render(&markdown).await {
- Ok(pdf) => {
- self.storage.write_file(&pdf_path(¬e), &pdf).await?;
+ Ok(content) => {
+ self.storage.write_file(&pdf, &content).await?;
None
}
Err(err) => {
// No stale pdf that would not match the markdown anymore
- self.storage.delete_file(&pdf_path(¬e)).await?;
+ self.storage.delete_file(&pdf).await?;
Some(match err {
RenderError::Failed(log) => log,
RenderError::Internal(message) => {
@@ -154,7 +213,7 @@ impl super::AppController {
let note = self
.db
- .update_lecture_note(note.id, title, pdf_error.is_none())
+ .update_lecture_note(note.id, title, note.path, pdf_error.is_none())
.await?;
Ok(SavedLectureNote { note, pdf_error })
}
@@ -209,6 +268,8 @@ fn validate_title(title: String) -> Result {
pub enum LectureNotesControllerError {
#[error("The title of the note is empty")]
EmptyTitle,
+ #[error("Another file of the course already has this name")]
+ NameTaken,
}
#[cfg(test)]
diff --git a/src/core/controller/courses.rs b/src/core/controller/courses.rs
index f293fc4..b6b8ae4 100644
--- a/src/core/controller/courses.rs
+++ b/src/core/controller/courses.rs
@@ -1,15 +1,21 @@
-//! Courses and their weekly time slots.
+//! Courses and associations, and their weekly time slots.
+//!
+//! Each one has a folder in the storage named after it, where its files live:
+//! `Cours/CS-477 Advanced operating systems`, `Associations/AGEPoly`. The folder is
+//! moved when the course is renamed.
use chrono::NaiveTime;
use thiserror::Error;
+use tracing::error;
use crate::core::{
controller::{ControllerError, is_http_url, non_blank},
models::{
- course::{Course, CourseData, CourseId},
+ course::{Course, CourseData, CourseId, CourseKind},
tag::{TagId, TagScope},
time_slot::{TimeSlotData, TimeSlotId},
},
+ repositories::RepositoryError,
};
/// The timetable shows monday to friday...
@@ -19,9 +25,43 @@ const LAST_WEEKDAY: u8 = 5;
const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap();
-/// Directory of the files of a course in the storage
-pub(crate) fn course_dir(id: CourseId) -> String {
- format!("courses/{id}")
+/// Longest name of a file or folder in the storage, in characters
+const MAX_FILE_NAME: usize = 120;
+
+/// Folder of a course in the storage, e.g. `Cours/CS-477 Advanced operating systems`
+fn course_folder(course: &CourseData) -> String {
+ let root = match course.kind {
+ CourseKind::Course => "Cours",
+ CourseKind::Association => "Associations",
+ };
+ let name = match &course.code {
+ Some(code) => format!("{code} {}", course.name),
+ None => course.name.clone(),
+ };
+ format!("{root}/{}", file_name(&name))
+}
+
+/// A name usable as a file or folder name on any NAS (samba included): without the
+/// characters Windows forbids, e.g. "Notes: memory/cache" -> "Notes - memory-cache"
+pub(crate) fn file_name(name: &str) -> String {
+ let replaced: String = name
+ .replace(':', " - ")
+ .chars()
+ .map(|c| match c {
+ '/' | '\\' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
+ c if c.is_control() => ' ',
+ c => c,
+ })
+ .collect();
+ let collapsed = replaced.split_whitespace().collect::>().join(" ");
+ let truncated: String = collapsed.chars().take(MAX_FILE_NAME).collect();
+ // A trailing dot or space is dropped by windows
+ let trimmed = truncated.trim_matches(|c: char| c == '.' || c == ' ');
+ if trimmed.is_empty() {
+ "Sans titre".to_owned()
+ } else {
+ trimmed.to_owned()
+ }
}
impl super::AppController {
@@ -35,22 +75,73 @@ impl super::AppController {
pub async fn create_course(&self, course: CourseData) -> Result {
let course = validate_course(course)?;
- self.db.create_course(course).await.map_err(Into::into)
+ let folder = course_folder(&course);
+ self.check_folder_free(&folder, None).await?;
+ self.db
+ .create_course(course, &folder)
+ .await
+ .map_err(Into::into)
}
+ /// Replaces a course, keeping its kind. Its folder follows its new name.
pub async fn update_course(
&self,
id: CourseId,
course: CourseData,
) -> Result {
- let course = validate_course(course)?;
- self.db.update_course(id, course).await.map_err(Into::into)
+ let existing = self.db.get_course(id).await?;
+ let course = validate_course(CourseData {
+ kind: existing.kind,
+ ..course
+ })?;
+ let folder = course_folder(&course);
+ if folder == existing.folder {
+ return self
+ .db
+ .update_course(id, course, &folder)
+ .await
+ .map_err(Into::into);
+ }
+
+ self.check_folder_free(&folder, Some(id)).await?;
+ let moved = match self.storage.rename(&existing.folder, &folder).await {
+ Ok(()) => true,
+ // No file yet
+ Err(RepositoryError::NotFound(_)) => false,
+ Err(err) => return Err(err.into()),
+ };
+ match self.db.update_course(id, course, &folder).await {
+ Ok(course) => Ok(course),
+ Err(err) => {
+ if moved && let Err(err) = self.storage.rename(&folder, &existing.folder).await {
+ error!("Unable to move {folder} back to {}: {err}", existing.folder);
+ }
+ Err(err.into())
+ }
+ }
}
/// Deletes the course with everything attached to it, its files included
pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> {
+ let course = self.db.get_course(id).await?;
self.db.delete_course(id).await?;
- self.storage.delete_dir(&course_dir(id)).await?;
+ self.storage.delete_dir(&course.folder).await?;
+ Ok(())
+ }
+
+ /// `FolderTaken` if another course (than `except`) already has this folder
+ async fn check_folder_free(
+ &self,
+ folder: &str,
+ except: Option,
+ ) -> Result<(), ControllerError> {
+ let courses = self.db.get_courses().await?;
+ if courses
+ .iter()
+ .any(|course| course.folder == folder && Some(course.id) != except)
+ {
+ return Err(CoursesControllerError::FolderTaken(folder.to_owned()).into());
+ }
Ok(())
}
@@ -98,7 +189,7 @@ fn validate_course(course: CourseData) -> Result {
.map(|teacher| teacher.trim().to_owned())
.filter(|teacher| !teacher.is_empty())
.collect();
- if teachers.is_empty() {
+ if teachers.is_empty() && course.kind == CourseKind::Course {
return Err(CoursesControllerError::NoTeacher.into());
}
@@ -111,6 +202,7 @@ fn validate_course(course: CourseData) -> Result {
}
Ok(CourseData {
+ kind: course.kind,
name,
code: non_blank(course.code),
teachers,
@@ -133,4 +225,39 @@ pub enum CoursesControllerError {
InvalidSlotTime,
#[error("Unknown tag {0}")]
UnknownTag(TagId),
+ #[error("Another course or association already uses the folder {0}")]
+ FolderTaken(String),
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn file_names_are_safe() {
+ assert_eq!(
+ file_name("Lecture 2 Notes: Memory/cache"),
+ "Lecture 2 Notes - Memory-cache"
+ );
+ assert_eq!(file_name(" a *b?. "), "a -b-");
+ assert_eq!(file_name("..."), "Sans titre");
+ }
+
+ #[test]
+ fn folder_of_a_course() {
+ let course = CourseData {
+ kind: CourseKind::Course,
+ name: "Advanced OS".to_owned(),
+ code: Some("CS-477".to_owned()),
+ teachers: Vec::new(),
+ main_page_url: None,
+ };
+ assert_eq!(course_folder(&course), "Cours/CS-477 Advanced OS");
+ let association = CourseData {
+ kind: CourseKind::Association,
+ code: None,
+ ..course
+ };
+ assert_eq!(course_folder(&association), "Associations/Advanced OS");
+ }
}
diff --git a/src/core/controller/focus.rs b/src/core/controller/focus.rs
new file mode 100644
index 0000000..ca97b56
--- /dev/null
+++ b/src/core/controller/focus.rs
@@ -0,0 +1,183 @@
+//! Focus sessions of the pomodoro. The pomodoro counts up: a session runs from its start
+//! until it is paused or stopped. Its time is added to the task, and counted in
+//! TimeTagger when it is configured, tagged with the course, the tags and the name of the
+//! task (`#cs-477 #homework #exercise-set-3`).
+//!
+//! TimeTagger never blocks a session: when it fails, the error is returned next to the
+//! result, to be shown.
+
+use chrono::Utc;
+use thiserror::Error;
+use tracing::warn;
+
+use crate::core::{
+ controller::ControllerError,
+ models::{
+ focus::{FocusStart, FocusStop, TimetaggerRecord},
+ tag::Tag,
+ task::{Task, TaskId},
+ },
+};
+
+/// Longest session that can be added at once
+const MAX_SESSION_SECONDS: u32 = 24 * 3600;
+
+impl super::AppController {
+ /// Starts a session on a task or a subtask
+ pub async fn start_focus(&self, task_id: TaskId) -> Result {
+ let task = self.db.get_task(task_id).await?;
+ let parent = match task.parent_id {
+ Some(parent_id) => Some(self.db.get_task(parent_id).await?),
+ None => None,
+ };
+ let course = self.db.get_course(task.course_id).await?;
+ let tag_ids = match &parent {
+ Some(parent) if task.tag_ids.is_empty() => parent.tag_ids.clone(),
+ _ => task.tag_ids.clone(),
+ };
+ let tags: Vec = self
+ .db
+ .get_tags(None)
+ .await?
+ .into_iter()
+ .filter(|tag| tag_ids.contains(&tag.id))
+ .collect();
+
+ let started_at = Utc::now();
+ let (timetagger, timetagger_error) = match &self.time_tracker {
+ None => (None, None),
+ Some(tracker) => {
+ let course_tag = course.code.as_deref().unwrap_or(&course.name);
+ let description = description(course_tag, &tags, parent.as_ref(), &task);
+ match tracker.start(&description, started_at).await {
+ Ok(record) => (Some(record), None),
+ Err(err) => {
+ warn!("Unable to start a TimeTagger record: {err}");
+ (None, Some(err.to_string()))
+ }
+ }
+ }
+ };
+
+ Ok(FocusStart {
+ course: (&course).into(),
+ task_id: task.id,
+ name: task.name,
+ parent_name: parent.map(|parent| parent.name),
+ tag_ids,
+ time_spent: task.time_spent,
+ started_at,
+ timetagger_enabled: self.time_tracker.is_some(),
+ timetagger,
+ timetagger_error,
+ })
+ }
+
+ /// Ends a session (or pauses it: resuming starts a new one): adds its `seconds` to
+ /// the task and stops its TimeTagger record
+ pub async fn stop_focus(
+ &self,
+ task_id: TaskId,
+ seconds: u32,
+ record: Option,
+ ) -> Result {
+ if seconds > MAX_SESSION_SECONDS {
+ return Err(FocusControllerError::TooLong.into());
+ }
+ let time_spent = self.db.add_time_spent(task_id, seconds as i32).await?;
+
+ let timetagger_error = match (&self.time_tracker, record) {
+ (Some(tracker), Some(record)) => match tracker.stop(&record, Utc::now()).await {
+ Ok(()) => None,
+ Err(err) => {
+ warn!("Unable to stop the TimeTagger record {}: {err}", record.key);
+ Some(err.to_string())
+ }
+ },
+ _ => None,
+ };
+ Ok(FocusStop {
+ time_spent,
+ timetagger_error,
+ })
+ }
+}
+
+/// `#cs-477 #homework #exercise-set-3`, the parent task before the subtask
+fn description(course: &str, tags: &[Tag], parent: Option<&Task>, task: &Task) -> String {
+ let mut words: Vec = Vec::new();
+ let names = std::iter::once(course)
+ .chain(
+ tags.iter()
+ .map(|tag| tag.slug.as_deref().unwrap_or(&tag.name.en)),
+ )
+ .chain(parent.map(|parent| parent.name.as_str()))
+ .chain(std::iter::once(task.name.as_str()));
+ for name in names {
+ let word = tag_word(name);
+ if !word.is_empty() && !words.contains(&word) {
+ words.push(word);
+ }
+ }
+ words
+ .iter()
+ .map(|word| format!("#{word}"))
+ .collect::>()
+ .join(" ")
+}
+
+/// A TimeTagger tag is a single lowercase word: "Exercise set 3" -> "exercise-set-3"
+fn tag_word(name: &str) -> String {
+ name.to_lowercase()
+ .split(|c: char| !c.is_alphanumeric())
+ .filter(|part| !part.is_empty())
+ .collect::>()
+ .join("-")
+}
+
+#[derive(Error, Debug)]
+pub enum FocusControllerError {
+ #[error("A session lasts at most 24 hours")]
+ TooLong,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::models::{localized_string::LocalizedString, tag::TagScope};
+
+ fn task(name: &str) -> Task {
+ Task {
+ id: 1,
+ course_id: 1,
+ parent_id: None,
+ name: name.to_owned(),
+ description: None,
+ links: Vec::new(),
+ deadline: None,
+ done: false,
+ tag_ids: Vec::new(),
+ time_spent: 0,
+ subtasks: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn describes_a_subtask() {
+ let tag = Tag {
+ id: 4,
+ name: LocalizedString {
+ fr: "Devoir".to_owned(),
+ en: "Homework".to_owned(),
+ },
+ color: "#000".to_owned(),
+ slug: Some("homework".to_owned()),
+ scopes: vec![TagScope::Task],
+ };
+ let parent = task("Série 3");
+ assert_eq!(
+ description("CS-477", &[tag], Some(&parent), &task("Exercice 2 (b)")),
+ "#cs-477 #homework #série-3 #exercice-2-b"
+ );
+ }
+}
diff --git a/src/core/controller/mod.rs b/src/core/controller/mod.rs
index 423e77f..b848dd2 100644
--- a/src/core/controller/mod.rs
+++ b/src/core/controller/mod.rs
@@ -13,17 +13,19 @@ use crate::core::{
controller::{
course_files::{LectureNotesControllerError, NotesSettings},
courses::CoursesControllerError,
+ focus::FocusControllerError,
grades::GradesControllerError,
tasks::TasksControllerError,
},
repositories::{
DatabaseRepository, RepositoryError, pdf_renderer::PdfRenderer,
- storage_repository::StorageRepository,
+ storage_repository::StorageRepository, time_tracker::TimeTracker,
},
};
pub mod course_files;
pub mod courses;
+pub mod focus;
pub mod grades;
pub mod tags;
pub mod tasks;
@@ -35,6 +37,8 @@ pub struct AppController {
storage: Arc,
renderer: Arc,
notes: Arc,
+ /// `None` when TimeTagger is not configured
+ time_tracker: Option>,
}
impl AppController {
@@ -43,12 +47,14 @@ impl AppController {
storage: Arc,
renderer: Arc,
notes: NotesSettings,
+ time_tracker: Option>,
) -> Self {
Self {
db,
storage,
renderer,
notes: Arc::new(notes),
+ time_tracker,
}
}
}
@@ -71,6 +77,8 @@ pub enum ControllerError {
Task(#[from] TasksControllerError),
#[error("Lecture note specific error: {0}")]
LectureNote(#[from] LectureNotesControllerError),
+ #[error("Focus specific error: {0}")]
+ Focus(#[from] FocusControllerError),
}
/// Trims a text, `None` when it is blank
diff --git a/src/core/controller/tasks.rs b/src/core/controller/tasks.rs
index f090ce6..08b236e 100644
--- a/src/core/controller/tasks.rs
+++ b/src/core/controller/tasks.rs
@@ -12,7 +12,7 @@ use crate::core::{
course::Course,
course::CourseId,
tag::TagScope,
- task::{CalendarTask, CourseTasks, Task, TaskData, TaskId},
+ task::{CalendarTask, CourseTasks, Task, TaskData, TaskId, TodoTask},
},
};
@@ -35,6 +35,29 @@ impl super::AppController {
Ok(calendar(tasks, &courses, from, to))
}
+ /// Tasks not done of every course, with their subtasks: the ones with a deadline
+ /// first, the closest first
+ pub async fn get_todo_tasks(&self) -> Result, ControllerError> {
+ let tasks = organize(self.db.get_todo_tasks().await?).tasks;
+ let courses: HashMap<_, Course> = self
+ .db
+ .get_courses()
+ .await?
+ .into_iter()
+ .map(|course| (course.id, course))
+ .collect();
+ Ok(tasks
+ .into_iter()
+ .filter_map(|task| {
+ let course = courses.get(&task.course_id)?;
+ Some(TodoTask {
+ course: course.into(),
+ task,
+ })
+ })
+ .collect())
+ }
+
pub async fn get_course_tasks(
&self,
course_id: CourseId,
@@ -189,6 +212,7 @@ fn calendar(
Some(CalendarTask {
task_id: task.id,
course_id: course.id,
+ course_kind: course.kind,
course_name: course.name.clone(),
course_code: course.code.clone(),
name: match parent {
@@ -229,6 +253,7 @@ mod tests {
use chrono::{TimeZone, Utc};
use super::*;
+ use crate::core::models::course::CourseKind;
fn task(id: TaskId, parent_id: Option, day: Option, done: bool) -> Task {
Task {
@@ -241,6 +266,7 @@ mod tests {
deadline: day.map(|day| Utc.with_ymd_and_hms(2026, 10, day, 12, 0, 0).unwrap()),
done,
tag_ids: vec![1],
+ time_spent: 0,
subtasks: Vec::new(),
}
}
@@ -264,10 +290,12 @@ mod tests {
fn calendar_names_and_tags_subtasks_after_their_parent() {
let course = Course {
id: 1,
+ kind: CourseKind::Course,
name: "OS".to_owned(),
code: None,
teachers: Vec::new(),
main_page_url: None,
+ folder: "Cours/OS".to_owned(),
slots: Vec::new(),
};
let from = Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap();
diff --git a/src/core/models/course.rs b/src/core/models/course.rs
index 79bfd3d..4efbdf2 100644
--- a/src/core/models/course.rs
+++ b/src/core/models/course.rs
@@ -5,26 +5,68 @@ use crate::core::models::time_slot::TimeSlot;
pub type CourseId = i32;
+/// A course, or an association: both have tasks, files and slots, only the courses have
+/// teachers and grades.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Course {
pub id: CourseId,
+ pub kind: CourseKind,
/// e.g. "Advanced operating systems"
pub name: String,
- /// e.g. "CS-477"
+ /// e.g. "CS-477", or the acronym of an association
pub code: Option,
- /// At least one
+ /// At least one for a course, none for an association
pub teachers: Vec,
/// Main page of the course, usually on moodle
pub main_page_url: Option,
+ /// Folder of its files in the storage, e.g. `Cours/CS-477 Advanced operating systems`
+ pub folder: String,
/// Weekly slots, sorted by weekday then start time
pub slots: Vec,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum CourseKind {
+ Course,
+ Association,
+}
+
+impl CourseKind {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ CourseKind::Course => "course",
+ CourseKind::Association => "association",
+ }
+ }
+}
+
/// Content of a course, used to create or replace one. The slots are managed apart.
#[derive(Debug, Clone)]
pub struct CourseData {
+ pub kind: CourseKind,
pub name: String,
pub code: Option,
pub teachers: Vec,
pub main_page_url: Option,
}
+
+/// What a task needs to show of its course
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct CourseSummary {
+ pub id: CourseId,
+ pub kind: CourseKind,
+ pub name: String,
+ pub code: Option,
+}
+
+impl From<&Course> for CourseSummary {
+ fn from(course: &Course) -> Self {
+ CourseSummary {
+ id: course.id,
+ kind: course.kind,
+ name: course.name.clone(),
+ code: course.code.clone(),
+ }
+ }
+}
diff --git a/src/core/models/course_file.rs b/src/core/models/course_file.rs
index 8429d5b..fd06943 100644
--- a/src/core/models/course_file.rs
+++ b/src/core/models/course_file.rs
@@ -7,7 +7,8 @@ use crate::core::models::{course::CourseId, tag::TagId};
pub type CourseFileId = i32;
/// A file of a course: lecture notes written in the app, or (soon) an uploaded file.
-/// Only the metadata is in the database, the content is in the storage (the NAS).
+/// Only the metadata is in the database, the content is in the storage (the NAS), in the
+/// folder of the course.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CourseFile {
pub id: CourseFileId,
@@ -15,6 +16,8 @@ pub struct CourseFile {
/// For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf
pub name: String,
pub kind: FileKind,
+ /// Path in the folder of the course, without extension, e.g. `Notes/Lecture 1 Notes`
+ pub path: String,
/// e.g. slides, notes
pub tag_ids: Vec,
/// For a note: false when the last rendering failed
diff --git a/src/core/models/focus.rs b/src/core/models/focus.rs
new file mode 100644
index 0000000..cba6960
--- /dev/null
+++ b/src/core/models/focus.rs
@@ -0,0 +1,45 @@
+use chrono::{DateTime, Utc};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::core::models::{course::CourseSummary, tag::TagId, task::TaskId};
+
+/// Record of TimeTagger (https://timetagger.app) counting a focus session. Kept by the
+/// client while the session runs, and sent back to stop it.
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct TimetaggerRecord {
+ pub key: String,
+ /// Start, unix timestamp in seconds
+ pub t1: i64,
+ /// Description holding the tags, e.g. `#cs-477 #homework #exercise-set-3`
+ pub ds: String,
+}
+
+/// A focus session started on a task
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct FocusStart {
+ pub course: CourseSummary,
+ pub task_id: TaskId,
+ pub name: String,
+ /// Name of the parent task, for a subtask
+ pub parent_name: Option,
+ /// Tags of the task, or of its parent for a subtask without any
+ pub tag_ids: Vec,
+ /// Seconds already spent on the task before this session
+ pub time_spent: i32,
+ pub started_at: DateTime,
+ /// Whether TimeTagger is configured
+ pub timetagger_enabled: bool,
+ /// The running record, `None` when TimeTagger is not configured or failed
+ pub timetagger: Option,
+ /// Why TimeTagger failed, the session runs anyway
+ pub timetagger_error: Option,
+}
+
+/// End of a focus session
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct FocusStop {
+ /// Seconds spent on the task, this session included
+ pub time_spent: i32,
+ pub timetagger_error: Option,
+}
diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs
index 9b173a4..90ec54e 100644
--- a/src/core/models/mod.rs
+++ b/src/core/models/mod.rs
@@ -1,5 +1,6 @@
pub mod course;
pub mod course_file;
+pub mod focus;
pub mod grade;
pub mod localized_string;
pub mod tag;
diff --git a/src/core/models/tag.rs b/src/core/models/tag.rs
index ea9d654..b59b765 100644
--- a/src/core/models/tag.rs
+++ b/src/core/models/tag.rs
@@ -15,6 +15,8 @@ pub struct Tag {
pub name: LocalizedString,
/// Hex color used to display what is tagged, e.g. `#2563eb`
pub color: String,
+ /// Stable name, e.g. `homework`
+ pub slug: Option,
/// What the tag can label
pub scopes: Vec,
}
diff --git a/src/core/models/task.rs b/src/core/models/task.rs
index 5db2250..1122809 100644
--- a/src/core/models/task.rs
+++ b/src/core/models/task.rs
@@ -2,7 +2,10 @@ use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use crate::core::models::{course::CourseId, tag::TagId};
+use crate::core::models::{
+ course::{CourseId, CourseKind, CourseSummary},
+ tag::TagId,
+};
pub type TaskId = i32;
@@ -20,6 +23,8 @@ pub struct Task {
pub deadline: Option>,
pub done: bool,
pub tag_ids: Vec,
+ /// Seconds spent working on it, counted by the pomodoro
+ pub time_spent: i32,
/// Sorted like the tasks. Always empty for a subtask: there is only one level.
pub subtasks: Vec,
}
@@ -48,6 +53,7 @@ pub struct CourseTasks {
pub struct CalendarTask {
pub task_id: TaskId,
pub course_id: CourseId,
+ pub course_kind: CourseKind,
pub course_name: String,
pub course_code: Option,
/// Name of the task, prefixed by the one of its parent for a subtask
@@ -58,3 +64,11 @@ pub struct CalendarTask {
/// Tags of the task, or of its parent for a subtask without any
pub tag_ids: Vec,
}
+
+/// A task not done yet, with its course, for the list of the things to do
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct TodoTask {
+ pub course: CourseSummary,
+ /// With all its subtasks, the ones done included
+ pub task: Task,
+}
diff --git a/src/core/repositories/course_files_repository.rs b/src/core/repositories/course_files_repository.rs
index d8675ea..a8933d3 100644
--- a/src/core/repositories/course_files_repository.rs
+++ b/src/core/repositories/course_files_repository.rs
@@ -22,14 +22,16 @@ pub trait CourseFilesRepository {
&self,
course_id: CourseId,
name: String,
+ path: String,
tag_ids: &[TagId],
) -> Result;
- /// Sets the name and `has_pdf`, and bumps `updated_at`. `NotFound` if the note does
- /// not exist.
+ /// Sets the name, path and `has_pdf`, and bumps `updated_at`. `NotFound` if the note
+ /// does not exist.
async fn update_lecture_note(
&self,
id: CourseFileId,
name: String,
+ path: String,
has_pdf: bool,
) -> Result;
/// `NotFound` if the file does not exist
diff --git a/src/core/repositories/courses_repository.rs b/src/core/repositories/courses_repository.rs
index 9c77e62..8847bc3 100644
--- a/src/core/repositories/courses_repository.rs
+++ b/src/core/repositories/courses_repository.rs
@@ -10,17 +10,22 @@ use crate::core::{
#[async_trait]
pub trait CoursesRepository {
- /// Every course with its slots, sorted by name
+ /// Every course and association with its slots, sorted by name
async fn get_courses(&self) -> Result, RepositoryError>;
/// `NotFound` if the course does not exist
async fn get_course(&self, id: CourseId) -> Result;
- /// Creates a course without any slot
- async fn create_course(&self, course: CourseData) -> Result;
- /// Replaces the course, keeping its slots. `NotFound` if the course does not exist.
+ /// Creates a course without any slot, its files in `folder`
+ async fn create_course(
+ &self,
+ course: CourseData,
+ folder: &str,
+ ) -> Result;
+ /// Replaces the course, keeping its slots and its kind. `NotFound` if the course does not exist.
async fn update_course(
&self,
id: CourseId,
course: CourseData,
+ folder: &str,
) -> Result;
/// Deletes the course and everything attached to it. `NotFound` if it does not exist.
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError>;
diff --git a/src/core/repositories/mod.rs b/src/core/repositories/mod.rs
index 2e89618..9d18b26 100644
--- a/src/core/repositories/mod.rs
+++ b/src/core/repositories/mod.rs
@@ -20,6 +20,7 @@ pub mod pdf_renderer;
pub mod storage_repository;
pub mod tags_repository;
pub mod tasks_repository;
+pub mod time_tracker;
/// Add every new repository trait here so the controller can use it through `db`
#[async_trait]
diff --git a/src/core/repositories/storage_repository.rs b/src/core/repositories/storage_repository.rs
index 4acd8f1..8a64d07 100644
--- a/src/core/repositories/storage_repository.rs
+++ b/src/core/repositories/storage_repository.rs
@@ -15,4 +15,7 @@ pub trait StorageRepository: Send + Sync {
async fn delete_file(&self, path: &str) -> Result<(), RepositoryError>;
/// Deletes a directory and everything in it. Does nothing if it does not exist.
async fn delete_dir(&self, path: &str) -> Result<(), RepositoryError>;
+ /// Moves a file or a directory, creating the missing parents of `to`. `NotFound` if
+ /// `from` does not exist, an error if `to` already exists.
+ async fn rename(&self, from: &str, to: &str) -> Result<(), RepositoryError>;
}
diff --git a/src/core/repositories/tasks_repository.rs b/src/core/repositories/tasks_repository.rs
index c389292..b15e2d8 100644
--- a/src/core/repositories/tasks_repository.rs
+++ b/src/core/repositories/tasks_repository.rs
@@ -20,6 +20,9 @@ pub trait TasksRepository {
from: DateTime,
to: DateTime,
) -> Result, RepositoryError>;
+ /// Tasks not done of every course, with all their subtasks. Flat: `subtasks` is always
+ /// empty.
+ async fn get_todo_tasks(&self) -> Result, RepositoryError>;
/// A single task, `subtasks` empty. `NotFound` if the task does not exist.
async fn get_task(&self, id: TaskId) -> Result;
async fn create_task(
@@ -30,6 +33,9 @@ pub trait TasksRepository {
) -> Result<(), RepositoryError>;
/// Replaces the task and its tags. `NotFound` if the task does not exist.
async fn update_task(&self, id: TaskId, task: TaskData) -> Result<(), RepositoryError>;
+ /// Adds `seconds` to the time spent on the task, returns the new total. `NotFound` if
+ /// the task does not exist.
+ async fn add_time_spent(&self, id: TaskId, seconds: i32) -> Result;
/// Deletes the task and its subtasks. `NotFound` if the task does not exist.
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError>;
}
diff --git a/src/core/repositories/time_tracker.rs b/src/core/repositories/time_tracker.rs
new file mode 100644
index 0000000..b48314c
--- /dev/null
+++ b/src/core/repositories/time_tracker.rs
@@ -0,0 +1,21 @@
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+
+use crate::core::{models::focus::TimetaggerRecord, repositories::RepositoryError};
+
+/// Where the focus sessions are also counted: TimeTagger
+#[async_trait]
+pub trait TimeTracker: Send + Sync {
+ /// Starts a running record described by `description` (text and `#tags`)
+ async fn start(
+ &self,
+ description: &str,
+ start: DateTime,
+ ) -> Result;
+ /// Stops a running record at `end`
+ async fn stop(
+ &self,
+ record: &TimetaggerRecord,
+ end: DateTime,
+ ) -> Result<(), RepositoryError>;
+}
diff --git a/src/main.rs b/src/main.rs
index 6e78ac8..6deab04 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,8 +5,14 @@ use tracing::info;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
- core::controller::{AppController, course_files::NotesSettings},
- services::{database::SqlxDatabase, pandoc::PandocRenderer, storage::LocalStorage},
+ core::{
+ controller::{AppController, course_files::NotesSettings},
+ repositories::time_tracker::TimeTracker,
+ },
+ services::{
+ database::SqlxDatabase, pandoc::PandocRenderer, storage::LocalStorage,
+ timetagger::Timetagger,
+ },
};
mod api;
@@ -44,11 +50,21 @@ async fn main() {
.map(|logo| template_dir.join(logo).display().to_string()),
};
+ let time_tracker = config.timetagger.as_ref().map(|timetagger| {
+ let url = timetagger
+ .url
+ .as_deref()
+ .unwrap_or("https://timetagger.app/api/v2");
+ info!("Focus sessions are counted in TimeTagger ({url})");
+ Arc::new(Timetagger::new(url, timetagger.token.clone())) as Arc
+ });
+
let controller = AppController::new(
Arc::new(Box::new(db)),
Arc::new(LocalStorage::new(&config.storage_dir)),
Arc::new(PandocRenderer::new(template_dir)),
notes,
+ time_tracker,
);
// Anything that is not an api route is served from the built frontend,
diff --git a/src/services/database/course_files.rs b/src/services/database/course_files.rs
index d37b528..b09f116 100644
--- a/src/services/database/course_files.rs
+++ b/src/services/database/course_files.rs
@@ -19,6 +19,7 @@ struct CourseFileDB {
pub course_id: i32,
pub name: String,
pub kind: String,
+ pub path: String,
pub tag_ids: Vec,
pub has_pdf: bool,
pub created_at: DateTime,
@@ -42,6 +43,7 @@ impl TryFrom for CourseFile {
)));
}
},
+ path: value.path,
tag_ids: value.tag_ids,
has_pdf: value.has_pdf,
created_at: value.created_at,
@@ -62,7 +64,7 @@ impl CourseFilesRepository for SqlxDatabase {
) -> Result, RepositoryError> {
query_as!(
CourseFileDB,
- r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
+ r#"SELECT f.id, f.course_id, f.name, f.kind, f.path, f.has_pdf, f.created_at, f.updated_at,
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
FROM course_files f
@@ -82,7 +84,7 @@ impl CourseFilesRepository for SqlxDatabase {
async fn get_course_file(&self, id: CourseFileId) -> Result {
query_as!(
CourseFileDB,
- r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
+ r#"SELECT f.id, f.course_id, f.name, f.kind, f.path, f.has_pdf, f.created_at, f.updated_at,
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
FROM course_files f
@@ -101,14 +103,16 @@ impl CourseFilesRepository for SqlxDatabase {
&self,
course_id: CourseId,
name: String,
+ path: String,
tag_ids: &[TagId],
) -> Result {
let mut tx = self.pool.begin().await?;
let id = query!(
- r#"INSERT INTO course_files (course_id, name, kind) VALUES ($1, $2, 'note')
+ r#"INSERT INTO course_files (course_id, name, kind, path) VALUES ($1, $2, 'note', $3)
RETURNING id"#,
course_id,
name,
+ path,
)
.fetch_one(&mut *tx)
.await?
@@ -129,13 +133,15 @@ impl CourseFilesRepository for SqlxDatabase {
&self,
id: CourseFileId,
name: String,
+ path: String,
has_pdf: bool,
) -> Result {
let result = query!(
- r#"UPDATE course_files SET name = $2, has_pdf = $3, updated_at = now()
+ r#"UPDATE course_files SET name = $2, path = $3, has_pdf = $4, updated_at = now()
WHERE id = $1 AND kind = 'note'"#,
id,
name,
+ path,
has_pdf,
)
.execute(&self.pool)
diff --git a/src/services/database/courses.rs b/src/services/database/courses.rs
index 619e52f..eead831 100644
--- a/src/services/database/courses.rs
+++ b/src/services/database/courses.rs
@@ -10,7 +10,7 @@ use sqlx::{PgExecutor, query, query_as};
use crate::{
core::{
models::{
- course::{Course, CourseData, CourseId},
+ course::{Course, CourseData, CourseId, CourseKind},
time_slot::{TimeSlot, TimeSlotData, TimeSlotId},
},
repositories::{RepositoryError, courses_repository::CoursesRepository},
@@ -20,22 +20,34 @@ use crate::{
struct CourseDB {
pub id: i32,
+ pub kind: String,
pub name: String,
pub code: Option,
pub teachers: Vec,
pub main_page_url: Option,
+ pub folder: String,
}
impl CourseDB {
- fn with_slots(self, slots: Vec) -> Course {
- Course {
+ fn with_slots(self, slots: Vec) -> Result {
+ Ok(Course {
id: self.id,
+ kind: match self.kind.as_str() {
+ "course" => CourseKind::Course,
+ "association" => CourseKind::Association,
+ kind => {
+ return Err(RepositoryError::TypeConversion(format!(
+ "invalid course kind {kind}"
+ )));
+ }
+ },
name: self.name,
code: self.code,
teachers: self.teachers,
main_page_url: self.main_page_url,
+ folder: self.folder,
slots,
- }
+ })
}
}
@@ -87,7 +99,8 @@ impl CoursesRepository for SqlxDatabase {
async fn get_courses(&self) -> Result, RepositoryError> {
let courses = query_as!(
CourseDB,
- r#"SELECT id, name, code, teachers, main_page_url FROM courses ORDER BY name, id"#
+ r#"SELECT id, kind, name, code, teachers, main_page_url, folder
+ FROM courses ORDER BY name, id"#
)
.fetch_all(&self.pool)
.await?;
@@ -108,65 +121,75 @@ impl CoursesRepository for SqlxDatabase {
.push(slot.try_into()?);
}
- Ok(courses
+ courses
.into_iter()
.map(|course| {
let slots = slots_by_course.remove(&course.id).unwrap_or_default();
course.with_slots(slots)
})
- .collect())
+ .collect()
}
async fn get_course(&self, id: CourseId) -> Result {
let row = query_as!(
CourseDB,
- r#"SELECT id, name, code, teachers, main_page_url FROM courses WHERE id = $1"#,
+ r#"SELECT id, kind, name, code, teachers, main_page_url, folder
+ FROM courses WHERE id = $1"#,
id,
)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
let slots = get_slots(&self.pool, id).await?;
- Ok(row.with_slots(slots))
+ row.with_slots(slots)
}
- async fn create_course(&self, course: CourseData) -> Result {
+ async fn create_course(
+ &self,
+ course: CourseData,
+ folder: &str,
+ ) -> Result {
let row = query_as!(
CourseDB,
- r#"INSERT INTO courses (name, code, teachers, main_page_url)
- VALUES ($1, $2, $3, $4)
- RETURNING id, name, code, teachers, main_page_url"#,
+ r#"INSERT INTO courses (kind, name, code, teachers, main_page_url, folder)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, kind, name, code, teachers, main_page_url, folder"#,
+ course.kind.as_str(),
course.name,
course.code,
&course.teachers,
course.main_page_url,
+ folder,
)
.fetch_one(&self.pool)
.await?;
- Ok(row.with_slots(Vec::new()))
+ row.with_slots(Vec::new())
}
async fn update_course(
&self,
id: CourseId,
course: CourseData,
+ folder: &str,
) -> Result {
let row = query_as!(
CourseDB,
- r#"UPDATE courses SET name = $2, code = $3, teachers = $4, main_page_url = $5
+ r#"UPDATE courses
+ SET name = $2, code = $3, teachers = $4, main_page_url = $5, folder = $6
WHERE id = $1
- RETURNING id, name, code, teachers, main_page_url"#,
+ RETURNING id, kind, name, code, teachers, main_page_url, folder"#,
id,
course.name,
course.code,
&course.teachers,
course.main_page_url,
+ folder,
)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
let slots = get_slots(&self.pool, id).await?;
- Ok(row.with_slots(slots))
+ row.with_slots(slots)
}
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError> {
diff --git a/src/services/database/tags.rs b/src/services/database/tags.rs
index 703cded..652dbda 100644
--- a/src/services/database/tags.rs
+++ b/src/services/database/tags.rs
@@ -14,6 +14,7 @@ struct TagDB {
pub id: i32,
pub name: Value,
pub color: String,
+ pub slug: Option,
pub scopes: Vec,
}
@@ -36,6 +37,7 @@ impl TryFrom for Tag {
id: value.id,
name: serde_json::from_value(value.name)?,
color: value.color,
+ slug: value.slug,
scopes: value
.scopes
.iter()
@@ -50,7 +52,7 @@ impl TagsRepository for SqlxDatabase {
async fn get_tags(&self, scope: Option) -> Result, RepositoryError> {
query_as!(
TagDB,
- r#"SELECT id, name, color, scopes FROM tags
+ r#"SELECT id, name, color, slug, scopes FROM tags
WHERE $1::TEXT IS NULL OR $1 = ANY(scopes)
ORDER BY id"#,
scope.map(TagScope::as_str),
diff --git a/src/services/database/tasks.rs b/src/services/database/tasks.rs
index 8e1fe2d..5f4666f 100644
--- a/src/services/database/tasks.rs
+++ b/src/services/database/tasks.rs
@@ -26,6 +26,7 @@ struct TaskDB {
pub links: Vec,
pub deadline: Option>,
pub done: bool,
+ pub time_spent: i32,
pub tag_ids: Vec,
}
@@ -41,6 +42,7 @@ impl From for Task {
deadline: value.deadline,
done: value.done,
tag_ids: value.tag_ids,
+ time_spent: value.time_spent,
subtasks: Vec::new(),
}
}
@@ -73,7 +75,7 @@ impl TasksRepository for SqlxDatabase {
Ok(query_as!(
TaskDB,
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
- t.deadline, t.done,
+ t.deadline, t.done, t.time_spent,
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
FROM tasks t
@@ -98,7 +100,7 @@ impl TasksRepository for SqlxDatabase {
Ok(query_as!(
TaskDB,
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
- t.deadline, t.done,
+ t.deadline, t.done, t.time_spent,
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
FROM tasks t
@@ -118,11 +120,32 @@ impl TasksRepository for SqlxDatabase {
.collect())
}
+ async fn get_todo_tasks(&self) -> Result, RepositoryError> {
+ Ok(query_as!(
+ TaskDB,
+ r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
+ t.deadline, t.done, t.time_spent,
+ COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
+ FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
+ FROM tasks t
+ LEFT JOIN task_tags tt ON tt.task_id = t.id
+ WHERE t.id IN (SELECT id FROM tasks WHERE parent_id IS NULL AND NOT done)
+ OR t.parent_id IN (SELECT id FROM tasks WHERE parent_id IS NULL AND NOT done)
+ GROUP BY t.id
+ ORDER BY t.id"#,
+ )
+ .fetch_all(&self.pool)
+ .await?
+ .into_iter()
+ .map(Into::into)
+ .collect())
+ }
+
async fn get_task(&self, id: TaskId) -> Result {
query_as!(
TaskDB,
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
- t.deadline, t.done,
+ t.deadline, t.done, t.time_spent,
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
FROM tasks t
@@ -189,6 +212,19 @@ impl TasksRepository for SqlxDatabase {
Ok(())
}
+ async fn add_time_spent(&self, id: TaskId, seconds: i32) -> Result {
+ query!(
+ r#"UPDATE tasks SET time_spent = time_spent + $2 WHERE id = $1
+ RETURNING time_spent"#,
+ id,
+ seconds,
+ )
+ .fetch_optional(&self.pool)
+ .await?
+ .map(|row| row.time_spent)
+ .ok_or_else(|| not_found(id))
+ }
+
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError> {
// The subtasks and the tags are removed by the `ON DELETE CASCADE`
let result = query!("DELETE FROM tasks WHERE id = $1", id)
diff --git a/src/services/mod.rs b/src/services/mod.rs
index 29e2ed1..ffb7c35 100644
--- a/src/services/mod.rs
+++ b/src/services/mod.rs
@@ -3,3 +3,4 @@
pub mod database;
pub mod pandoc;
pub mod storage;
+pub mod timetagger;
diff --git a/src/services/storage.rs b/src/services/storage.rs
index 9e1a6bf..aac8ce6 100644
--- a/src/services/storage.rs
+++ b/src/services/storage.rs
@@ -75,4 +75,30 @@ impl StorageRepository for LocalStorage {
_ => Ok(()),
}
}
+
+ async fn rename(&self, from: &str, to: &str) -> Result<(), RepositoryError> {
+ let source = self.resolve(from)?;
+ let target = self.resolve(to)?;
+ if !fs::try_exists(&source)
+ .await
+ .map_err(|err| io_error(from, err))?
+ {
+ return Err(RepositoryError::NotFound(format!("file {from}")));
+ }
+ // `rename` would silently replace a file
+ if fs::try_exists(&target)
+ .await
+ .map_err(|err| io_error(to, err))?
+ {
+ return Err(RepositoryError::Internal(format!("{to} already exists")));
+ }
+ if let Some(parent) = target.parent() {
+ fs::create_dir_all(parent)
+ .await
+ .map_err(|err| io_error(to, err))?;
+ }
+ fs::rename(source, target)
+ .await
+ .map_err(|err| io_error(from, err))
+ }
}
diff --git a/src/services/timetagger.rs b/src/services/timetagger.rs
new file mode 100644
index 0000000..074fcbb
--- /dev/null
+++ b/src/services/timetagger.rs
@@ -0,0 +1,135 @@
+//! Client of the TimeTagger api (https://timetagger.app/api_docs). A record is running
+//! while its start `t1` equals its end `t2`: it is created that way, then written again
+//! with its end to stop it.
+
+use std::{
+ hash::{BuildHasher, RandomState},
+ time::{Duration, SystemTime},
+};
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::core::{
+ models::focus::TimetaggerRecord,
+ repositories::{RepositoryError, time_tracker::TimeTracker},
+};
+
+const TIMEOUT: Duration = Duration::from_secs(10);
+
+pub struct Timetagger {
+ client: reqwest::Client,
+ /// e.g. `https://timetagger.app/api/v2`
+ url: String,
+ token: String,
+}
+
+impl Timetagger {
+ pub fn new(url: &str, token: String) -> Self {
+ Self {
+ client: reqwest::Client::builder()
+ .timeout(TIMEOUT)
+ .build()
+ .expect("Unable to build the http client"),
+ url: url.trim_end_matches('/').to_owned(),
+ token,
+ }
+ }
+
+ async fn put(&self, record: Record<'_>) -> Result<(), RepositoryError> {
+ let response = self
+ .client
+ .put(format!("{}/records", self.url))
+ .header("authtoken", &self.token)
+ .json(&[record])
+ .send()
+ .await
+ .map_err(|err| RepositoryError::Internal(format!("TimeTagger unreachable: {err}")))?;
+ let status = response.status();
+ if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
+ return Err(RepositoryError::Internal(format!(
+ "TimeTagger answered {status}: {body}"
+ )));
+ }
+ let result: PutResult = response.json().await.map_err(|err| {
+ RepositoryError::Internal(format!("Unexpected answer of TimeTagger: {err}"))
+ })?;
+ if !result.failed.is_empty() {
+ return Err(RepositoryError::Internal(format!(
+ "TimeTagger refused the record: {}",
+ result.errors.join(", ")
+ )));
+ }
+ Ok(())
+ }
+}
+
+#[derive(Serialize)]
+struct Record<'a> {
+ key: &'a str,
+ t1: i64,
+ t2: i64,
+ ds: &'a str,
+ /// Modification time
+ mt: i64,
+ /// Server time, set by the server
+ st: f64,
+}
+
+#[derive(Deserialize)]
+struct PutResult {
+ #[serde(default)]
+ failed: Vec,
+ #[serde(default)]
+ errors: Vec,
+}
+
+/// Unique enough key for a record
+fn random_key() -> String {
+ format!("{:016x}", RandomState::new().hash_one(SystemTime::now()))
+}
+
+#[async_trait]
+impl TimeTracker for Timetagger {
+ async fn start(
+ &self,
+ description: &str,
+ start: DateTime,
+ ) -> Result {
+ let key = random_key();
+ let t1 = start.timestamp();
+ self.put(Record {
+ key: &key,
+ t1,
+ t2: t1,
+ ds: description,
+ mt: Utc::now().timestamp(),
+ st: 0.0,
+ })
+ .await?;
+ Ok(TimetaggerRecord {
+ key,
+ t1,
+ ds: description.to_owned(),
+ })
+ }
+
+ async fn stop(
+ &self,
+ record: &TimetaggerRecord,
+ end: DateTime,
+ ) -> Result<(), RepositoryError> {
+ self.put(Record {
+ key: &record.key,
+ t1: record.t1,
+ // A record of zero second would stay running
+ t2: end.timestamp().max(record.t1 + 1),
+ ds: &record.ds,
+ mt: Utc::now().timestamp(),
+ st: 0.0,
+ })
+ .await
+ }
+}
diff --git a/src/utils/config.rs b/src/utils/config.rs
index 7e6d248..4558eed 100644
--- a/src/utils/config.rs
+++ b/src/utils/config.rs
@@ -30,16 +30,27 @@ pub struct NotesConfig {
pub student_id: Option,
}
+/// TimeTagger account the focus sessions are counted in
+#[derive(Deserialize, Clone)]
+pub struct TimetaggerConfig {
+ /// Api of the TimeTagger server, `https://timetagger.app/api/v2` by default
+ pub url: Option,
+ /// Api token, from the account page of TimeTagger
+ pub token: String,
+}
+
#[derive(Deserialize, Clone)]
pub struct AppConfig {
pub server: ServerConfig,
pub postgres: PostgresConfig,
/// Directory containing the built frontend
pub frontend_dir: String,
- /// Where the files of the courses are stored (lecture notes, pdf...). A local
- /// directory standing in for the NAS for now.
+ /// Where the files of the courses and associations are stored (lecture notes,
+ /// pdf...), one folder each. A local directory standing in for the NAS for now.
pub storage_dir: String,
pub notes: NotesConfig,
+ /// Optional: without it the pomodoro only counts the time in the app
+ pub timetagger: Option,
}
impl AppConfig {