todo.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. package caldav
  2. /*
  3. todo.go - VTODO (reminders) support for the CalDAV server
  4. Exposes the ArozOS Reminders web-app data (user:/Document/Reminders/data.json)
  5. as a CalDAV calendar collection of VTODO components so iOS Reminders can sync
  6. bidirectionally. Recurring reminders are supported by passing RRULE through
  7. in both directions.
  8. The data file is shared with the Reminders web-app and has the shape
  9. { "lists": [...], "reminders": [...] }; only the reminders array is touched
  10. by CalDAV writes, and unknown list data is preserved untouched.
  11. */
  12. import (
  13. "crypto/md5"
  14. "encoding/json"
  15. "fmt"
  16. "net/http"
  17. "os"
  18. "path/filepath"
  19. "strconv"
  20. "strings"
  21. "time"
  22. "imuslab.com/arozos/mod/info/logger"
  23. )
  24. // ReminderItem mirrors a single reminder object in the Reminders data.json.
  25. type ReminderItem struct {
  26. ID string `json:"id"`
  27. ListID string `json:"listId"`
  28. ParentID string `json:"parentId,omitempty"`
  29. Title string `json:"title"`
  30. Notes string `json:"notes,omitempty"`
  31. Completed bool `json:"completed"`
  32. CompletedAt int64 `json:"completedAt,omitempty"`
  33. Flagged bool `json:"flagged,omitempty"`
  34. Priority int `json:"priority"` // 0=None 1=Low 2=Medium 3=High
  35. DueDate string `json:"dueDate,omitempty"` // YYYY-MM-DD
  36. DueTime string `json:"dueTime,omitempty"` // HH:MM
  37. URL string `json:"url,omitempty"`
  38. CreatedAt int64 `json:"createdAt,omitempty"`
  39. Order int64 `json:"order,omitempty"`
  40. RRule string `json:"rrule,omitempty"` // RFC 5545 recurrence rule, e.g. "FREQ=DAILY"
  41. }
  42. // reminderList mirrors a list object; kept opaque so it round-trips untouched.
  43. type reminderList struct {
  44. ID string `json:"id"`
  45. Name string `json:"name"`
  46. Color string `json:"color,omitempty"`
  47. Icon string `json:"icon,omitempty"`
  48. Order int64 `json:"order,omitempty"`
  49. }
  50. // reminderStore is the on-disk shape of data.json.
  51. type reminderStore struct {
  52. Lists []reminderList `json:"lists"`
  53. Reminders []ReminderItem `json:"reminders"`
  54. }
  55. // ── Storage helpers ───────────────────────────────────────────────────────────
  56. func (h *Handler) remindersFilePath(username string) (string, error) {
  57. userObj, err := h.userHandler.GetUserInfoFromUsername(username)
  58. if err != nil {
  59. return "", err
  60. }
  61. fsh, err := userObj.GetHomeFileSystemHandler()
  62. if err != nil {
  63. return "", err
  64. }
  65. return fsh.FileSystemAbstraction.VirtualPathToRealPath("/Document/Reminders/data.json", username)
  66. }
  67. func (h *Handler) loadReminderStore(username string) (reminderStore, error) {
  68. store := reminderStore{Lists: []reminderList{}, Reminders: []ReminderItem{}}
  69. p, err := h.remindersFilePath(username)
  70. if err != nil {
  71. return store, err
  72. }
  73. data, err := os.ReadFile(p)
  74. if err != nil {
  75. if os.IsNotExist(err) {
  76. return store, nil
  77. }
  78. return store, err
  79. }
  80. if err := json.Unmarshal(data, &store); err != nil {
  81. return store, err
  82. }
  83. return store, nil
  84. }
  85. func (h *Handler) loadReminders(username string) ([]ReminderItem, error) {
  86. store, err := h.loadReminderStore(username)
  87. if err != nil {
  88. return nil, err
  89. }
  90. return store.Reminders, nil
  91. }
  92. func (h *Handler) saveReminderStore(username string, store reminderStore) error {
  93. p, err := h.remindersFilePath(username)
  94. if err != nil {
  95. return err
  96. }
  97. if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
  98. return err
  99. }
  100. if store.Lists == nil {
  101. store.Lists = []reminderList{}
  102. }
  103. if store.Reminders == nil {
  104. store.Reminders = []ReminderItem{}
  105. }
  106. data, err := json.MarshalIndent(store, "", " ")
  107. if err != nil {
  108. return err
  109. }
  110. return os.WriteFile(p, data, 0644)
  111. }
  112. // defaultListID returns the list a CalDAV-created reminder should belong to:
  113. // the first existing list, or "ls_default" to match the web-app's seed list.
  114. func defaultListID(store reminderStore) string {
  115. if len(store.Lists) > 0 {
  116. return store.Lists[0].ID
  117. }
  118. return "ls_default"
  119. }
  120. // ── HTTP handlers (called from caldav.go dispatch) ─────────────────────────────
  121. func (h *Handler) handleGetReminder(w http.ResponseWriter, r *http.Request, id string, username string) {
  122. reminders, err := h.loadReminders(username)
  123. if err != nil {
  124. http.Error(w, "Internal Server Error", http.StatusInternalServerError)
  125. return
  126. }
  127. for _, rm := range reminders {
  128. if rm.ID == id {
  129. ics := reminderToICS(rm)
  130. w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
  131. w.Header().Set("ETag", reminderETag(rm))
  132. if r.Method == http.MethodHead {
  133. w.WriteHeader(http.StatusOK)
  134. return
  135. }
  136. w.WriteHeader(http.StatusOK)
  137. fmt.Fprint(w, ics)
  138. return
  139. }
  140. }
  141. http.Error(w, "Not Found", http.StatusNotFound)
  142. }
  143. func (h *Handler) handlePutReminder(w http.ResponseWriter, body string, id string, username string) {
  144. newRm, err := icsToReminder(body, id)
  145. if err != nil || newRm.Title == "" {
  146. logger.PrintAndLog("CalDAV", "PUT: VTODO parse failed for "+id, err)
  147. http.Error(w, "Bad Request: cannot parse VTODO", http.StatusBadRequest)
  148. return
  149. }
  150. newRm.ID = id
  151. h.mu.Lock()
  152. defer h.mu.Unlock()
  153. store, err := h.loadReminderStore(username)
  154. if err != nil {
  155. http.Error(w, "Internal Server Error", http.StatusInternalServerError)
  156. return
  157. }
  158. isUpdate := false
  159. for i, rm := range store.Reminders {
  160. if rm.ID == id {
  161. // Preserve fields that VTODO does not carry (list membership,
  162. // hierarchy, creation/order metadata) across the update.
  163. newRm.ListID = rm.ListID
  164. newRm.ParentID = rm.ParentID
  165. newRm.CreatedAt = rm.CreatedAt
  166. newRm.Order = rm.Order
  167. store.Reminders[i] = newRm
  168. isUpdate = true
  169. break
  170. }
  171. }
  172. if !isUpdate {
  173. newRm.ListID = defaultListID(store)
  174. newRm.CreatedAt = time.Now().UnixMilli()
  175. newRm.Order = newRm.CreatedAt
  176. store.Reminders = append(store.Reminders, newRm)
  177. }
  178. if err := h.saveReminderStore(username, store); err != nil {
  179. logger.PrintAndLog("CalDAV", "save reminders for "+username, err)
  180. http.Error(w, "Internal Server Error", http.StatusInternalServerError)
  181. return
  182. }
  183. w.Header().Set("ETag", reminderETag(newRm))
  184. if isUpdate {
  185. w.WriteHeader(http.StatusNoContent)
  186. } else {
  187. w.WriteHeader(http.StatusCreated)
  188. }
  189. }
  190. func (h *Handler) handleDeleteReminder(w http.ResponseWriter, id string, username string) {
  191. h.mu.Lock()
  192. defer h.mu.Unlock()
  193. store, err := h.loadReminderStore(username)
  194. if err != nil {
  195. http.Error(w, "Internal Server Error", http.StatusInternalServerError)
  196. return
  197. }
  198. kept := make([]ReminderItem, 0, len(store.Reminders))
  199. found := false
  200. for _, rm := range store.Reminders {
  201. // Deleting a reminder also removes its sub-tasks, mirroring the web-app.
  202. if rm.ID == id {
  203. found = true
  204. continue
  205. }
  206. if rm.ParentID == id {
  207. continue
  208. }
  209. kept = append(kept, rm)
  210. }
  211. if !found {
  212. http.Error(w, "Not Found", http.StatusNotFound)
  213. return
  214. }
  215. store.Reminders = kept
  216. if err := h.saveReminderStore(username, store); err != nil {
  217. logger.PrintAndLog("CalDAV", "save reminders for "+username, err)
  218. http.Error(w, "Internal Server Error", http.StatusInternalServerError)
  219. return
  220. }
  221. w.WriteHeader(http.StatusNoContent)
  222. }
  223. // ── VTODO conversion ───────────────────────────────────────────────────────────
  224. // reminderToICS serialises a ReminderItem as a VCALENDAR / VTODO string.
  225. func reminderToICS(rm ReminderItem) string {
  226. var sb strings.Builder
  227. sb.WriteString("BEGIN:VCALENDAR\r\n")
  228. sb.WriteString("VERSION:2.0\r\n")
  229. sb.WriteString("PRODID:-//ArozOS//CalDAV//EN\r\n")
  230. sb.WriteString("BEGIN:VTODO\r\n")
  231. sb.WriteString("UID:" + rm.ID + "@arozos\r\n")
  232. sb.WriteString("SUMMARY:" + escapeICSText(rm.Title) + "\r\n")
  233. if rm.Notes != "" {
  234. sb.WriteString("DESCRIPTION:" + escapeICSText(rm.Notes) + "\r\n")
  235. }
  236. if rm.DueDate != "" {
  237. sb.WriteString(reminderDueToICS(rm.DueDate, rm.DueTime) + "\r\n")
  238. }
  239. if prio := arozPriorityToICS(rm.Priority); prio > 0 {
  240. sb.WriteString("PRIORITY:" + strconv.Itoa(prio) + "\r\n")
  241. }
  242. if rm.Completed {
  243. sb.WriteString("STATUS:COMPLETED\r\n")
  244. sb.WriteString("PERCENT-COMPLETE:100\r\n")
  245. if rm.CompletedAt > 0 {
  246. sb.WriteString("COMPLETED:" + time.UnixMilli(rm.CompletedAt).UTC().Format("20060102T150405Z") + "\r\n")
  247. }
  248. } else {
  249. sb.WriteString("STATUS:NEEDS-ACTION\r\n")
  250. }
  251. if rm.URL != "" {
  252. sb.WriteString("URL:" + escapeICSText(rm.URL) + "\r\n")
  253. }
  254. if rm.ParentID != "" {
  255. sb.WriteString("RELATED-TO:" + rm.ParentID + "@arozos\r\n")
  256. }
  257. if rrule := normalizeRRule(rm.RRule); rrule != "" {
  258. sb.WriteString("RRULE:" + rrule + "\r\n")
  259. }
  260. sb.WriteString("END:VTODO\r\n")
  261. sb.WriteString("END:VCALENDAR\r\n")
  262. return sb.String()
  263. }
  264. // icsToReminder parses a VCALENDAR string containing a VTODO into a ReminderItem.
  265. // idHint is used as the reminder ID when the UID is absent or needs normalising.
  266. func icsToReminder(icsData string, idHint string) (ReminderItem, error) {
  267. lines := unfoldICSLines(icsData)
  268. rm := ReminderItem{ID: idHint}
  269. inVTodo := false
  270. for _, line := range lines {
  271. switch strings.ToUpper(line) {
  272. case "BEGIN:VTODO":
  273. inVTodo = true
  274. continue
  275. case "END:VTODO":
  276. inVTodo = false
  277. continue
  278. }
  279. if !inVTodo {
  280. continue
  281. }
  282. key, val := splitICSLine(line)
  283. baseKey := strings.ToUpper(strings.Split(key, ";")[0])
  284. switch baseKey {
  285. case "UID":
  286. uid := unescapeICSText(strings.TrimSpace(val))
  287. uid = strings.TrimSuffix(uid, "@arozos")
  288. if uid != "" {
  289. rm.ID = uid
  290. }
  291. case "SUMMARY":
  292. rm.Title = unescapeICSText(val)
  293. case "DESCRIPTION":
  294. rm.Notes = unescapeICSText(val)
  295. case "DUE":
  296. rm.DueDate, rm.DueTime = parseICSDue(key, val)
  297. case "PRIORITY":
  298. if v, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
  299. rm.Priority = icsPriorityToAroz(v)
  300. }
  301. case "STATUS":
  302. rm.Completed = strings.EqualFold(strings.TrimSpace(val), "COMPLETED")
  303. case "COMPLETED":
  304. if t, _ := parseICSDateTime(key, val); !t.IsZero() {
  305. rm.CompletedAt = t.UnixMilli()
  306. }
  307. case "PERCENT-COMPLETE":
  308. if v, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && v >= 100 {
  309. rm.Completed = true
  310. }
  311. case "URL":
  312. rm.URL = unescapeICSText(val)
  313. case "RELATED-TO":
  314. rm.ParentID = strings.TrimSuffix(unescapeICSText(strings.TrimSpace(val)), "@arozos")
  315. case "RRULE":
  316. rm.RRule = normalizeRRule(strings.TrimSpace(val))
  317. }
  318. }
  319. if rm.Completed && rm.CompletedAt == 0 {
  320. rm.CompletedAt = time.Now().UnixMilli()
  321. }
  322. return rm, nil
  323. }
  324. // reminderDueToICS formats a reminder due date/time as a DUE property value.
  325. // Reminders use floating local time (no timezone) so the literal wall-clock
  326. // time set on the desktop matches what iOS shows and vice-versa.
  327. func reminderDueToICS(dueDate, dueTime string) string {
  328. d := strings.ReplaceAll(dueDate, "-", "")
  329. if dueTime == "" {
  330. return "DUE;VALUE=DATE:" + d
  331. }
  332. t := strings.ReplaceAll(dueTime, ":", "")
  333. return "DUE:" + d + "T" + t + "00"
  334. }
  335. // parseICSDue extracts a reminder's date (YYYY-MM-DD) and time (HH:MM) from a
  336. // DUE property, treating the value as floating local time. An all-day DUE
  337. // (VALUE=DATE) yields an empty time.
  338. func parseICSDue(key, val string) (dueDate, dueTime string) {
  339. val = strings.TrimSpace(val)
  340. val = strings.TrimSuffix(val, "Z") // ignore UTC designator; treat as wall-clock
  341. if len(val) >= 8 {
  342. dueDate = val[:4] + "-" + val[4:6] + "-" + val[6:8]
  343. }
  344. if strings.Contains(strings.ToUpper(key), "VALUE=DATE") {
  345. return dueDate, ""
  346. }
  347. if idx := strings.Index(val, "T"); idx >= 0 {
  348. t := val[idx+1:]
  349. if len(t) >= 4 {
  350. dueTime = t[:2] + ":" + t[2:4]
  351. }
  352. }
  353. return dueDate, dueTime
  354. }
  355. // arozPriorityToICS maps the ArozOS priority (0..3) to an iCalendar PRIORITY
  356. // (1=high .. 9=low, 0=undefined) using the values iOS understands.
  357. func arozPriorityToICS(p int) int {
  358. switch p {
  359. case 3: // High
  360. return 1
  361. case 2: // Medium
  362. return 5
  363. case 1: // Low
  364. return 9
  365. default: // None
  366. return 0
  367. }
  368. }
  369. // icsPriorityToAroz maps an iCalendar PRIORITY back to the ArozOS scale.
  370. func icsPriorityToAroz(p int) int {
  371. switch {
  372. case p <= 0:
  373. return 0 // None / undefined
  374. case p <= 4:
  375. return 3 // High
  376. case p == 5:
  377. return 2 // Medium
  378. default:
  379. return 1 // Low (6..9)
  380. }
  381. }
  382. // reminderETag returns a quoted MD5 ETag for the given reminder.
  383. func reminderETag(rm ReminderItem) string {
  384. data, _ := json.Marshal(rm)
  385. h := md5.Sum(data)
  386. return fmt.Sprintf(`"%x"`, h)
  387. }
  388. // remindersCTag returns an unquoted MD5 sync token for the whole collection.
  389. func remindersCTag(reminders []ReminderItem) string {
  390. data, _ := json.Marshal(reminders)
  391. h := md5.Sum(data)
  392. return fmt.Sprintf("%x", h)
  393. }