do not crash on malformed schedule files
[sispare-qt.git] / session.cc
blob4bb9766a386324578b6c3345c25dc4b8d64a459d
1 /*
2 * Copyright (c) 2021, S. Gilles <sgilles@sgilles.net>
4 * Permission to use, copy, modify, and/or distribute this software
5 * for any purpose with or without fee is hereby granted, provided
6 * that the above copyright notice and this permission notice appear
7 * in all copies.
9 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
10 * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
11 * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
12 * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR
13 * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
14 * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
15 * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
16 * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18 #include "session.hh"
20 #include <filesystem>
21 #include <fstream>
22 #include <iostream>
23 #include <random>
24 #include <regex>
25 #include <string>
27 std::string Session::salt = "will be overwritten";
30 * Start up a session attached to a directory like "~/.data/sispare".
31 * Read in cards, set up state so that the UI can ask what's going on,
32 * etc.
34 Session::Session(const std::filesystem::path& data_dir) :
36 data_dir(data_dir),
37 session_start(std::chrono::system_clock::now()),
38 current_action(Currently_doing::Trivial),
39 cards(),
40 card_status(),
41 schedule_files_to_clean(),
42 cards_to_delete()
46 * Read all the cards in the schedule directory, figure out what
47 * we need to review today.
49 const std::regex ymd_matcher("^(\\d+)-(\\d+)-(\\d+)$");
50 const std::time_t now = std::chrono::system_clock::to_time_t(session_start);
51 std::ostringstream gcc_still_doesnt_have_std_format;
53 gcc_still_doesnt_have_std_format << now;
54 Session::salt = gcc_still_doesnt_have_std_format.str();
55 std::filesystem::create_directories(data_dir / "schedule");
56 std::filesystem::create_directories(data_dir / "cards");
58 for (const auto& entry : std::filesystem::directory_iterator(data_dir / "schedule")) {
59 const std::filesystem::path& p = entry.path();
62 * p is something like
63 * "~/.data/sispare/schedule/2099-10-27. Is it that day
64 * yet?
66 const std::string ymd = p.stem().string();
67 std::smatch ymd_matches;
68 struct tm this_tm = {};
70 if (std::regex_search(ymd, ymd_matches, ymd_matcher)) {
71 this_tm.tm_year = std::stoi(ymd_matches[1]) - 1900;
72 this_tm.tm_mon = std::stoi(ymd_matches[2]) - 1;
73 this_tm.tm_mday = std::stoi(ymd_matches[3]);
74 const std::time_t sched_time = mktime(&this_tm);
76 if (sched_time < now) {
77 schedule_files_to_clean.push_back(p);
78 std::ifstream sched_is(p);
79 std::string line;
82 * The schedule file just contains a
83 * number of lines, and
84 * "~/.data/sispare/cards/<line>" is the
85 * card to review.
87 while (getline(sched_is, line)) {
88 try {
89 cards.insert(std::move(Card::mk(data_dir / "cards" / line)));
90 } catch (const std::exception& ex) {
91 cards_to_delete.insert(data_dir / "cards" / line);
98 if (!cards.empty()) {
99 current_action = Currently_doing::Viewing_A;
100 cards_it = cards.cbegin();
101 it_index = 1;
105 bool
106 Session::have_cards_to_review() const
108 return !cards.empty();
111 Currently_doing
112 Session::get_current_action() const
114 return current_action;
117 std::optional<const std::string>
118 Session::get_current_A_side() const
120 if (cards.empty() ||
121 cards_it == cards.end()) {
122 return std::nullopt;
125 return cards_it->a;
128 std::optional<const std::string>
129 Session::get_current_B_side() const
131 if (cards.empty() ||
132 cards_it == cards.end()) {
133 return std::nullopt;
136 return cards_it->b;
139 const std::string
140 Session::get_progress_string() const
142 std::ostringstream out;
144 out << it_index << "/" << cards.size();
146 return out.str();
149 const std::string
150 Session::get_statistics() const
152 std::ostringstream out;
153 std::size_t num_passed = 0;
154 std::size_t num_failed = 0;
156 for (auto& c : cards) {
157 auto ret = card_status.find(c.path);
159 if (ret != card_status.end()) {
160 switch (ret->second) {
161 case Review_status::Pass_easy:
162 case Review_status::Pass_hard:
163 num_passed++;
164 break;
165 case Review_status::Fail:
166 num_failed++;
167 break;
168 case Review_status::Unreviewed:
169 break;
174 out << "Finished." << std::endl << std::endl;
175 out << "Passed: " << num_passed << std::endl;
176 out << "Failed: " << num_failed << std::endl;
178 return out.str();
181 const std::string
182 Session::get_previously_seen_string() const
184 if (cards.empty() ||
185 cards_it == cards.end()) {
186 return "";
189 auto ret = card_status.find(cards_it->path);
191 if (ret == card_status.end()) {
192 return "";
195 switch (ret->second) {
196 case Review_status::Pass_easy:
197 return "Marked as passed";
198 case Review_status::Pass_hard:
199 return "Marked as passed (hard)";
200 case Review_status::Fail:
201 return "Marked as failed";
202 case Review_status::Unreviewed:
203 return "";
206 return "";
209 bool
210 Session::have_next_card() const
212 return !cards.empty() &&
213 cards_it != cards.end() &&
214 std::next(cards_it) != cards.end();
217 bool
218 Session::have_prev_card() const
220 return !cards.empty() &&
221 cards_it != cards.begin();
224 void
225 Session::flip_current_card()
227 switch (current_action) {
228 case Currently_doing::Viewing_A:
229 current_action = Currently_doing::Viewing_B;
230 break;
231 case Currently_doing::Viewing_B:
232 current_action = Currently_doing::Viewing_A;
233 break;
234 default:
235 break;
239 void
240 Session::mark_current_card_as(Review_status review_status)
242 if (cards.empty() ||
243 cards_it == cards.end()) {
244 return;
247 card_status[cards_it->path] = review_status;
248 move_next_card();
251 void
252 Session::move_next_card()
254 if (cards.empty()) {
255 return;
258 if (have_next_card()) {
259 current_action = Currently_doing::Viewing_A;
260 } else {
261 current_action = Currently_doing::Finished;
264 cards_it++;
265 it_index++;
268 void
269 Session::move_prev_card()
271 if (cards.empty()) {
272 return;
275 if (have_prev_card()) {
276 cards_it--;
277 it_index--;
278 current_action = Currently_doing::Viewing_A;
282 void
283 Session::update_cards_on_disk()
286 * First, go through all the cards and update their levels
287 * according to whether they were passed or failed today. Write
288 * out their new level (to the card's own directory) and the
289 * next time they should be reviewed (to somewhere in the
290 * schedule directory).
292 int cutoff_level = (std::end(timing_data) - std::begin(timing_data)) - 1;
293 std::random_device rand_dev;
294 std::mt19937 gen(rand_dev());
296 for (auto& c : cards) {
297 int new_level = c.level;
298 auto ret = card_status.find(c.path);
300 if (ret == card_status.end()) {
301 continue;
304 /* Calculate and write new level. */
305 switch (ret->second) {
306 case Review_status::Unreviewed:
307 break;
308 case Review_status::Pass_easy:
309 new_level = std::min(new_level + 1, cutoff_level);
310 break;
311 case Review_status::Pass_hard:
312 new_level = std::max(new_level - 1, 1);
313 break;
314 case Review_status::Fail:
315 new_level = 1;
316 break;
319 std::ofstream out_level(c.path / "level");
321 out_level << new_level << std::endl;
323 if (new_level > cutoff_level) {
324 /* Don't reschedule this card */
325 continue;
328 /* Choose when it'll get reviewed. Note uniform_int_distribution is inclusive of endpoints. */
329 std::pair<int, int> delay_range = timing_data[new_level];
330 std::uniform_int_distribution<> possible_waits(delay_range.first, delay_range.second);
331 int days_to_wait = possible_waits(gen);
332 std::time_t then = std::chrono::system_clock::to_time_t(session_start + std::chrono::days(days_to_wait));
333 struct tm then_tm = *localtime(&then);
335 /* Write to the proper schedule file */
336 std::ostringstream date;
338 date << (then_tm.tm_year + 1900);
339 date << "-";
340 date << std::setfill('0') << std::setw(2) << (then_tm.tm_mon + 1);
341 date << "-";
342 date << std::setfill('0') << std::setw(2) << then_tm.tm_mday;
343 const std::string sched_day = date.str();
344 std::ofstream out_sched_day(data_dir / "schedule" / sched_day, std::ios_base::app);
346 out_sched_day << c.path.stem().string() << std::endl;
350 * Next, go through all the schedule files that we read from on
351 * startup. Every line in those files that corresponds to a card
352 * we reviewed needs to be wiped. If we quit the session early,
353 * there might be some lines left over. Schedule files that end
354 * up completely empty should just be deleted from disk.
356 for (auto& f : schedule_files_to_clean) {
357 std::ifstream ii(f);
358 std::ostringstream filtered;
360 if (!ii.is_open()) {
361 continue;
365 * The filter should only keep the cards we didn't
366 * review in this session. The cards that we did were
367 * re-scheduled above.
369 * Additionally, cards that caused errors on session
370 * load should be erased.
372 std::string line;
374 while (std::getline(ii, line)) {
375 /* Don't output deleted cards */
376 auto ret1 = cards_to_delete.find(data_dir / "cards" / line);
378 if (ret1 != cards_to_delete.end()) {
379 continue;
382 /* Don't output cards that were passed/failed this session */
383 auto ret2 = card_status.find(data_dir / "cards" / line);
385 if (ret2 == card_status.end()) {
386 filtered << line << std::endl;
387 continue;
390 switch (ret2->second) {
391 case Review_status::Pass_easy:
392 case Review_status::Pass_hard:
393 case Review_status::Fail:
394 break;
395 case Review_status::Unreviewed:
396 filtered << line << std::endl;
397 break;
401 ii.close();
402 std::string filtered_str = filtered.str();
405 * Only keep the schedule file around if it has some
406 * cards in it. Otherwise, get rid of it.
408 if (filtered_str.size() > 0) {
409 std::ofstream oo(f);
411 oo << filtered.str();
412 } else {
413 std::filesystem::remove(f);