NoteService.vala 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql.Orm;
  5. using Inversion;
  6. using ClankerSaddle.Data;
  7. namespace ClankerSaddle.Services {
  8. /**
  9. * The dashboard scratchpad (goal 8 task 3): personal notes/todo
  10. * rows for jotting down batches of changes BEFORE they are ready
  11. * to be full-on goals or tasks. `notes` carries no project (or
  12. * any other) semantics — it is the human's own list — and a note
  13. * promotes into a DRAFT goal with the body as the brief, after
  14. * which it is frozen and linked to that goal.
  15. */
  16. public class NoteService : Object {
  17. private OrmSession db = inject<OrmSession>();
  18. private GoalService goals = inject<GoalService>();
  19. private StatePulse pulse = inject<StatePulse>();
  20. /** Loads the note identified by `id`, or null. */
  21. public async Note? get_note(int64 id) throws Error {
  22. return yield db.query<Note>()
  23. .where(expr("id == $0", elem<int64?>(id)))
  24. .first_async();
  25. }
  26. /**
  27. * Inserts a note. The body is trimmed and must be non-empty;
  28. * a note starts undone and unpromoted.
  29. */
  30. public async Note add_note(string body) throws Error {
  31. string trimmed = body.strip();
  32. if (trimmed.length == 0) {
  33. throw new Error(Quark.from_string("clanker-saddle"), 95,
  34. "A note body is required");
  35. }
  36. var note = new Note() {
  37. body = trimmed,
  38. done = false,
  39. created_at = new DateTime.now_local()
  40. };
  41. yield db.insert_async<Note>(note);
  42. yield pulse.bump(0, "note added");
  43. return note;
  44. }
  45. /**
  46. * Every note, undone first, then newest first (the sort runs
  47. * in memory — the ORM's order_by carries a single key).
  48. * Promotion freezes a note but does not finish it, so a
  49. * promoted note keeps its place by its own done flag.
  50. */
  51. public async Enumerable<Note> list_notes() throws Error {
  52. var rows = yield db.query<Note>()
  53. .order_by_desc(expr("id"))
  54. .materialise_async();
  55. return rows.sort((a, b) => {
  56. if (a.done != b.done) {
  57. return a.done ? 1 : -1;
  58. }
  59. return a.id > b.id ? -1 : 1;
  60. });
  61. }
  62. /**
  63. * Sets (or clears) a note's done flag, stamping done_at on
  64. * the transition. Returns the saved note, or null when no
  65. * such note exists. A promoted note stays toggleable — done
  66. * still means "I dealt with this", promotion only locks the
  67. * create-goal path.
  68. */
  69. public async Note? set_done(int64 note_id, bool done) throws Error {
  70. var note = yield get_note(note_id);
  71. if (note == null) {
  72. return null;
  73. }
  74. if (((!)note).done != done) {
  75. ((!)note).done = done;
  76. ((!)note).done_at = done ? new DateTime.now_local() : null;
  77. yield db.update_async<Note>((!)note);
  78. yield pulse.bump(0, "note toggled");
  79. }
  80. return note;
  81. }
  82. /** Deletes the note (idempotent: an absent note is fine). */
  83. public async void delete_note(int64 note_id) throws Error {
  84. var note = yield get_note(note_id);
  85. if (note == null) {
  86. return;
  87. }
  88. yield db.delete_async<Note>((!)note);
  89. yield pulse.bump(0, "note deleted");
  90. }
  91. /**
  92. * Derives the promote-to-goal title from the note body: the
  93. * first non-empty line, clamped to ~80 characters (the clamp
  94. * appends an ellipsis when it actually cut anything).
  95. */
  96. public static string title_from_body(string body) {
  97. string first_line = "";
  98. foreach (string line in body.split("\n")) {
  99. if (line.strip().length > 0) {
  100. first_line = line.strip();
  101. break;
  102. }
  103. }
  104. if (first_line.char_count() > 80) {
  105. first_line = first_line.substring(0,
  106. (long) first_line.index_of_nth_char(80)) + "…";
  107. }
  108. return first_line.length > 0 ? first_line : "Note goal";
  109. }
  110. /**
  111. * Promotes an UNDONE, unpromoted note into a DRAFT goal on
  112. * `project_id` (title = {@link title_from_body}, brief = the
  113. * full note body, branch = `branch_name`), stamps the note's
  114. * goal_id and returns the created goal. The human lands on
  115. * the goal's page to set links/branch details and press
  116. * start. A promoted (or done) note refuses — the row is
  117. * frozen once its goal exists.
  118. */
  119. public async Goal promote_to_goal(int64 note_id, int64 project_id,
  120. string branch_name) throws Error {
  121. var note = yield get_note(note_id);
  122. if (note == null) {
  123. throw new Error(Quark.from_string("clanker-saddle"), 96,
  124. @"No note exists with id $(note_id)");
  125. }
  126. if (((!)note).goal_id > 0) {
  127. throw new Error(Quark.from_string("clanker-saddle"), 97,
  128. @"Note #$(note_id) already promoted to goal #$(((!)note).goal_id)");
  129. }
  130. if (((!)note).done) {
  131. throw new Error(Quark.from_string("clanker-saddle"), 98,
  132. @"Note #$(note_id) is done — only open notes promote to goals");
  133. }
  134. var goal = yield goals.create_goal(project_id,
  135. title_from_body(((!)note).body), ((!)note).body, branch_name, null, 3);
  136. ((!)note).goal_id = goal.id;
  137. yield db.update_async<Note>((!)note);
  138. yield pulse.bump(0, "note promoted");
  139. return goal;
  140. }
  141. }
  142. }