diff --git a/api/QuestionsApi.Tests/QuestionBookmarkTests.cs b/api/QuestionsApi.Tests/QuestionBookmarkTests.cs new file mode 100644 index 0000000..5d11796 --- /dev/null +++ b/api/QuestionsApi.Tests/QuestionBookmarkTests.cs @@ -0,0 +1,29 @@ +using QuestionsApi.Models; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class QuestionBookmarkTests +{ + [Fact] + public void PublicResponsesDoNotExposeUserOrMongoIdentifiers() + { + Assert.Null(typeof(QuestionBookmarkSummary).GetProperty(nameof(QuestionBookmark.UserId))); + Assert.Null(typeof(QuestionBookmarkSummary).GetProperty(nameof(QuestionBookmark.Id))); + Assert.Null(typeof(QuestionBookmarkMutationResponse).GetProperty(nameof(QuestionBookmark.UserId))); + Assert.Null(typeof(QuestionBookmarkMutationResponse).GetProperty(nameof(QuestionBookmark.Id))); + } + + [Fact] + public void QuestionResponseDefaultsToNotBookmarked() + { + var response = QuestionResponse.FromModel(new Question + { + ExternalId = "bookmark-test", + Choices = [new QuestionChoice { Id = "A", Text = "Answer" }], + CorrectChoiceId = "A" + }); + + Assert.False(response.IsBookmarked); + } +} diff --git a/api/QuestionsApi/Controllers/BookmarksController.cs b/api/QuestionsApi/Controllers/BookmarksController.cs new file mode 100644 index 0000000..fc7be8e --- /dev/null +++ b/api/QuestionsApi/Controllers/BookmarksController.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController, Route("api/bookmarks"), Authorize(Roles = "USER,SUPER")] +public sealed class BookmarksController( + QuestionBookmarkStore bookmarks, + QuestionStore questions, + AnswerStatisticsStore statistics, + RatingStore ratings) : ControllerBase +{ + [HttpGet] + public async Task> List( + [FromQuery] string query = "", + [FromQuery] string system = "", + [FromQuery] string discipline = "", + [FromQuery] string sort = "newest", + [FromQuery] string? cursor = null, + [FromQuery] int limit = 20) + { + if (query.Length > 200) return BadRequest("query must be 200 characters or fewer"); + if (system.Length > 120 || discipline.Length > 120) + return BadRequest("filter values must be 120 characters or fewer"); + try + { + var page = await bookmarks.SearchAsync( + UserId(), query, system, discipline, sort, cursor, limit, User.IsInRole("SUPER")); + var facets = await bookmarks.GetFacetsAsync(UserId(), User.IsInRole("SUPER")); + return Ok(new QuestionBookmarkPageResponse + { + Items = page.Items.Select(item => new QuestionBookmarkSummary + { + QuestionExternalId = item.Question.ExternalId, + Stem = item.Question.Stem, + LeadIn = item.Question.LeadIn, + System = item.Question.System, + Discipline = item.Question.Discipline, + Difficulty = item.Question.Difficulty, + HasMedia = item.Question.Media.Count > 0, + Tags = item.Question.Tags, + CreatedUtc = item.Bookmark.CreatedUtc + }).ToList(), + NextCursor = page.NextCursor, + HasMore = page.HasMore, + Systems = facets.Systems, + Disciplines = facets.Disciplines + }); + } + catch (ArgumentException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPut("{externalId}")] + public async Task> Add(string externalId) + { + var normalized = NormalizeExternalId(externalId); + if (normalized is null) return BadRequest("externalId is invalid"); + var question = await questions.GetByExternalIdAsync(normalized); + if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) return NotFound(); + await bookmarks.AddAsync(UserId(), question.ExternalId); + return Ok(new QuestionBookmarkMutationResponse + { + QuestionExternalId = question.ExternalId, + IsBookmarked = true + }); + } + + [HttpDelete("{externalId}")] + public async Task> Remove(string externalId) + { + var normalized = NormalizeExternalId(externalId); + if (normalized is null) return BadRequest("externalId is invalid"); + await bookmarks.RemoveAsync(UserId(), normalized); + return Ok(new QuestionBookmarkMutationResponse + { + QuestionExternalId = normalized, + IsBookmarked = false + }); + } + + [HttpGet("{externalId}/question")] + public async Task> Question(string externalId) + { + var normalized = NormalizeExternalId(externalId); + if (normalized is null) return BadRequest("externalId is invalid"); + if (!await bookmarks.IsBookmarkedAsync(UserId(), normalized)) return NotFound(); + var question = await questions.GetByExternalIdAsync(normalized); + if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + { + await bookmarks.DeleteStaleAsync(UserId(), normalized); + return NotFound(); + } + var response = QuestionResponse.FromModel( + question, + await statistics.GetPercentagesForQuestionAsync(normalized), + await ratings.GetQuestionEloAsync(normalized)); + response.IsBookmarked = true; + return Ok(response); + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; + + private static string? NormalizeExternalId(string value) + { + var normalized = value.Trim(); + return normalized.Length is > 0 and <= 200 ? normalized : null; + } +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index e2f82cc..4824317 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -17,6 +17,7 @@ public class QuestionsController : ControllerBase private readonly QuestionMediaStore _media; private readonly AnswerStatisticsStore _statistics; private readonly ServiceMetrics _metrics; + private readonly QuestionBookmarkStore _bookmarks; public QuestionsController( QuestionStore questions, @@ -24,7 +25,8 @@ public class QuestionsController : ControllerBase RatingStore ratings, QuestionMediaStore media, AnswerStatisticsStore statistics, - ServiceMetrics metrics) + ServiceMetrics metrics, + QuestionBookmarkStore bookmarks) { _questions = questions; _attempts = attempts; @@ -32,6 +34,7 @@ public class QuestionsController : ControllerBase _media = media; _statistics = statistics; _metrics = metrics; + _bookmarks = bookmarks; } [HttpGet("systems")] @@ -49,14 +52,17 @@ public class QuestionsController : ControllerBase var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId)); var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId)); + var bookmarkedIds = await _bookmarks.GetBookmarkedIdsAsync(UserId(), questions.Select(q => q.ExternalId)); return Ok(questions.Select(q => { percentages.TryGetValue(q.ExternalId, out var questionPercentages); - return QuestionResponse.FromModel( + var response = QuestionResponse.FromModel( q, questionPercentages, questionElos.GetValueOrDefault(q.ExternalId, 1000), req.SupportsChoicePools); + response.IsBookmarked = bookmarkedIds.Contains(q.ExternalId); + return response; }).ToList()); } @@ -90,9 +96,16 @@ public class QuestionsController : ControllerBase var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId); var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId); - return Ok(QuestionResponse.FromModel(question, percentages, questionElo)); + var response = QuestionResponse.FromModel(question, percentages, questionElo); + response.IsBookmarked = await _bookmarks.IsBookmarkedAsync(UserId(), question.ExternalId); + return Ok(response); } + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; + [HttpPost("attempts/first")] [Authorize(Roles = "USER,SUPER")] public async Task RecordFirstAttempt([FromBody] FirstQuestionAttemptRequest req) diff --git a/api/QuestionsApi/Models/QuestionBookmark.cs b/api/QuestionsApi/Models/QuestionBookmark.cs new file mode 100644 index 0000000..b3cc234 --- /dev/null +++ b/api/QuestionsApi/Models/QuestionBookmark.cs @@ -0,0 +1,41 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public sealed class QuestionBookmark +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string QuestionExternalId { get; set; } = ""; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class QuestionBookmarkSummary +{ + public string QuestionExternalId { get; set; } = ""; + public string Stem { get; set; } = ""; + public string LeadIn { get; set; } = ""; + public string System { get; set; } = ""; + public string Discipline { get; set; } = ""; + public int Difficulty { get; set; } + public bool HasMedia { get; set; } + public List Tags { get; set; } = []; + public DateTime CreatedUtc { get; set; } +} + +public sealed class QuestionBookmarkPageResponse +{ + public List Items { get; set; } = []; + public string? NextCursor { get; set; } + public bool HasMore { get; set; } + public List Systems { get; set; } = []; + public List Disciplines { get; set; } = []; +} + +public sealed class QuestionBookmarkMutationResponse +{ + public string QuestionExternalId { get; set; } = ""; + public bool IsBookmarked { get; set; } +} diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index e97172e..63590cd 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -25,6 +25,7 @@ public class QuestionResponse public List Media { get; set; } = []; public List Tags { get; set; } = []; public List References { get; set; } = []; + public bool IsBookmarked { get; set; } public static QuestionResponse FromModel( Question question, diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index fe806a1..ff56610 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -22,6 +22,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/Services/QuestionBookmarkStore.cs b/api/QuestionsApi/Services/QuestionBookmarkStore.cs new file mode 100644 index 0000000..bfdc6fd --- /dev/null +++ b/api/QuestionsApi/Services/QuestionBookmarkStore.cs @@ -0,0 +1,215 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class QuestionBookmarkStore +{ + private readonly IMongoCollection _bookmarks; + + public QuestionBookmarkStore(MongoDatabaseProvider mongo) + { + _bookmarks = mongo.Database.GetCollection("QuestionBookmarks"); + _bookmarks.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys + .Ascending(item => item.UserId) + .Ascending(item => item.QuestionExternalId), + new CreateIndexOptions { Unique = true, Name = "bookmark_unique_user_question" }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(item => item.UserId) + .Descending(item => item.CreatedUtc) + .Descending(item => item.Id), + new CreateIndexOptions { Name = "bookmark_user_created_desc" }) + ]); + } + + public async Task AddAsync(string userId, string externalId) + { + var now = DateTime.UtcNow; + try + { + return await _bookmarks.FindOneAndUpdateAsync( + item => item.UserId == userId && item.QuestionExternalId == externalId, + Builders.Update + .SetOnInsert(item => item.UserId, userId) + .SetOnInsert(item => item.QuestionExternalId, externalId) + .SetOnInsert(item => item.CreatedUtc, now) + .Set(item => item.UpdatedUtc, now), + new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }); + } + catch (MongoCommandException exception) when (exception.Code == 11000) + { + return await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId) + .FirstAsync(); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId) + .FirstAsync(); + } + } + + public async Task RemoveAsync(string userId, string externalId) => + await _bookmarks.DeleteOneAsync(item => item.UserId == userId && item.QuestionExternalId == externalId); + + public async Task IsBookmarkedAsync(string userId, string externalId) => + await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId).AnyAsync(); + + public async Task> GetBookmarkedIdsAsync(string userId, IEnumerable externalIds) + { + var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList(); + if (ids.Count == 0) return []; + var found = await _bookmarks.Find(item => item.UserId == userId && ids.Contains(item.QuestionExternalId)) + .Project(item => item.QuestionExternalId) + .ToListAsync(); + return found.ToHashSet(StringComparer.Ordinal); + } + + public async Task<(List<(QuestionBookmark Bookmark, Question Question)> Items, string? NextCursor, bool HasMore)> SearchAsync( + string userId, + string query, + string system, + string discipline, + string sort, + string? cursor, + int requestedLimit, + bool includeUnpublished) + { + var descending = !string.Equals(sort, "oldest", StringComparison.OrdinalIgnoreCase); + var limit = Math.Clamp(requestedLimit, 1, 50); + var match = new BsonDocument("UserId", userId); + if (!string.IsNullOrWhiteSpace(cursor)) + { + var decoded = DecodeCursor(cursor); + var comparison = descending ? "$lt" : "$gt"; + match["$or"] = new BsonArray + { + new BsonDocument("CreatedUtc", new BsonDocument(comparison, decoded.CreatedUtc)), + new BsonDocument + { + { "CreatedUtc", decoded.CreatedUtc }, + { "_id", new BsonDocument(comparison, decoded.Id) } + } + }; + } + + var questionMatch = new BsonDocument(); + if (!includeUnpublished) questionMatch["Question.IsPublished"] = true; + if (!string.IsNullOrWhiteSpace(system) && system != "All Systems") questionMatch["Question.System"] = system.Trim(); + if (!string.IsNullOrWhiteSpace(discipline)) questionMatch["Question.Discipline"] = discipline.Trim(); + if (!string.IsNullOrWhiteSpace(query)) + { + var regex = new BsonRegularExpression(Regex.Escape(query.Trim()), "i"); + questionMatch["$or"] = new BsonArray + { + new BsonDocument("Question.Stem", regex), + new BsonDocument("Question.LeadIn", regex), + new BsonDocument("Question.Tags", regex) + }; + } + + var direction = descending ? -1 : 1; + var pipeline = new List + { + new("$match", match), + new("$lookup", new BsonDocument + { + { "from", "Questions" }, + { "localField", "QuestionExternalId" }, + { "foreignField", "ExternalId" }, + { "as", "Question" } + }), + new("$unwind", "$Question") + }; + if (questionMatch.ElementCount > 0) pipeline.Add(new BsonDocument("$match", questionMatch)); + pipeline.Add(new BsonDocument("$sort", new BsonDocument { { "CreatedUtc", direction }, { "_id", direction } })); + pipeline.Add(new BsonDocument("$limit", limit + 1)); + + var docs = await _bookmarks.Aggregate(pipeline).ToListAsync(); + var hasMore = docs.Count > limit; + if (hasMore) docs.RemoveAt(docs.Count - 1); + var items = new List<(QuestionBookmark, Question)>(); + foreach (var doc in docs) + { + var bookmarkDoc = doc.DeepClone().AsBsonDocument; + bookmarkDoc.Remove("Question"); + items.Add(( + BsonSerializer.Deserialize(bookmarkDoc), + BsonSerializer.Deserialize(doc["Question"].AsBsonDocument))); + } + var nextCursor = hasMore && items.Count > 0 ? EncodeCursor(items[^1].Item1) : null; + return (items, nextCursor, hasMore); + } + + public async Task DeleteStaleAsync(string userId, string externalId) => + await RemoveAsync(userId, externalId); + + public async Task<(List Systems, List Disciplines)> GetFacetsAsync( + string userId, + bool includeUnpublished) + { + var pipeline = new List + { + new("$match", new BsonDocument("UserId", userId)), + new("$lookup", new BsonDocument + { + { "from", "Questions" }, + { "localField", "QuestionExternalId" }, + { "foreignField", "ExternalId" }, + { "as", "Question" } + }), + new("$unwind", "$Question") + }; + if (!includeUnpublished) + pipeline.Add(new BsonDocument("$match", new BsonDocument("Question.IsPublished", true))); + pipeline.Add(new BsonDocument("$group", new BsonDocument + { + { "_id", BsonNull.Value }, + { "Systems", new BsonDocument("$addToSet", "$Question.System") }, + { "Disciplines", new BsonDocument("$addToSet", "$Question.Discipline") } + })); + var result = await _bookmarks.Aggregate(pipeline).FirstOrDefaultAsync(); + if (result is null) return ([], []); + static List Values(BsonDocument source, string field) => source[field].AsBsonArray + .Where(value => value.IsString && !string.IsNullOrWhiteSpace(value.AsString)) + .Select(value => value.AsString) + .OrderBy(value => value) + .ToList(); + return (Values(result, "Systems"), Values(result, "Disciplines")); + } + + private static string EncodeCursor(QuestionBookmark bookmark) + { + var payload = JsonSerializer.Serialize(new CursorPayload(bookmark.CreatedUtc, bookmark.Id ?? "")); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)); + } + + private static (DateTime CreatedUtc, ObjectId Id) DecodeCursor(string cursor) + { + try + { + var json = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); + var payload = JsonSerializer.Deserialize(json) + ?? throw new ArgumentException("cursor is invalid"); + if (!ObjectId.TryParse(payload.Id, out var id)) throw new ArgumentException("cursor is invalid"); + return (payload.CreatedUtc, id); + } + catch (Exception exception) when (exception is FormatException or JsonException) + { + throw new ArgumentException("cursor is invalid", nameof(cursor)); + } + } + + private sealed record CursorPayload(DateTime CreatedUtc, string Id); +} diff --git a/project.godot b/project.godot index decb894..80f8375 100644 --- a/project.godot +++ b/project.godot @@ -29,6 +29,7 @@ ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd" CCSService="*res://scripts/autoload/ccs_service.gd" FeedbackService="*res://scripts/autoload/feedback_service.gd" NotesService="*res://scripts/autoload/notes_service.gd" +BookmarkService="*res://scripts/autoload/bookmark_service.gd" [display] diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 7b7bdc6..1d4da39 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -77,6 +77,36 @@ func start_core_run() -> bool: return true +func start_bookmark_question(question: Dictionary) -> bool: + if question.is_empty(): + return false + system_name = str(question.get("system", "All Systems")) + mode = "Tutor" + session_kind = "bookmark" + active_questions = [_prepare_question_choices(question)] + answers = [null] + current_index = 0 + seconds_remaining = 0.0 + session_initial_seconds = 90.0 + current_question_started_at = Time.get_ticks_msec() / 1000.0 + current_question_pause_started_at = 0.0 + block_started = true + session_changed.emit() + return true + + +func replace_bookmark_question(question: Dictionary) -> bool: + if session_kind != "bookmark" or question.is_empty(): + return false + active_questions = [_prepare_question_choices(question)] + answers = [null] + current_index = 0 + current_question_started_at = Time.get_ticks_msec() / 1000.0 + current_question_pause_started_at = 0.0 + session_changed.emit() + return true + + func start_simulation_block(questions: Array, block_index: int, total_blocks: int, deadline_unix: float) -> void: system_name = "All Systems" mode = "Simulation" @@ -190,6 +220,44 @@ func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) -> } +func submit_bookmark_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary: + var question := get_current_question() + if session_kind != "bookmark" or question.is_empty(): + return {} + var answer := { + "question": question.duplicate(true), + "selected_choice_id": choice_id, + "is_correct": choice_id == str(question.get("correct_choice_id", "")), + "eliminated_choice_ids": eliminated.duplicate(), + "flagged": flagged, + "elapsed_seconds": get_current_elapsed_seconds(), + "target_seconds": 0.0 + } + if bool(answer.is_correct): + answers[current_index] = answer + session_changed.emit() + return {"answer": answer, "is_correct": bool(answer.is_correct), "final": bool(answer.is_correct)} + + +func skip_bookmark_question(eliminated: Array, flagged: bool) -> Dictionary: + var question := get_current_question() + if session_kind != "bookmark" or question.is_empty(): + return {} + var answer := { + "question": question.duplicate(true), + "selected_choice_id": "", + "is_correct": false, + "is_skip": true, + "eliminated_choice_ids": eliminated.duplicate(), + "flagged": flagged, + "elapsed_seconds": get_current_elapsed_seconds(), + "target_seconds": 0.0 + } + answers[current_index] = answer + session_changed.emit() + return answer + + func skip_core_question(eliminated: Array, flagged: bool) -> Dictionary: var question := get_current_question() if question.is_empty(): @@ -248,7 +316,7 @@ func get_progress_text() -> String: func finish_block() -> Dictionary: - if session_kind == "core": + if session_kind in ["core", "bookmark"]: block_started = false session_changed.emit() return {} diff --git a/scripts/autoload/bookmark_service.gd b/scripts/autoload/bookmark_service.gd new file mode 100644 index 0000000..ba8771a --- /dev/null +++ b/scripts/autoload/bookmark_service.gd @@ -0,0 +1,493 @@ +extends Node + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_API_URL := "https://s3q.ranaze.com/api/bookmarks" +const REQUEST_TIMEOUT_SECONDS := 30.0 +const RETRY_DELAY_SECONDS := 5.0 +const QUEUE_PATH := "user://pending_question_bookmarks.json" + +signal status_changed(question_id: String, is_bookmarked: bool, pending: bool) +signal list_changed +signal list_failed(message: String) +signal question_loaded(question: Dictionary) +signal question_failed(message: String) + +var api_url := DEFAULT_API_URL +var items: Array = [] +var next_cursor := "" +var has_more := false +var list_loading := false +var list_error := "" +var remote_list_loaded := false +var pending_list_reload := false +var pending_list_filters: Dictionary = {} +var active_filters := {"query": "", "system": "", "discipline": "", "sort": "newest"} +var available_systems: Array = [] +var available_disciplines: Array = [] +var practice_ids: Array = [] +var practice_index := -1 +var practice_waiting_for_page := false +var requested_question_id := "" + +var bookmarked: Dictionary = {} +var pending_operations: Array = [] +var active_account_key := "" +var active_operation_index := -1 +var mutation_token := "" + +var mutation_request: HTTPRequest +var list_request: HTTPRequest +var question_request: HTTPRequest +var retry_timer: Timer +var auth_state: Node + + +func _ready() -> void: + auth_state = get_node("/root/AuthState") + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/bookmarks_api_url", DEFAULT_API_URL)), + "/api/bookmarks") + mutation_request = _create_request(_on_mutation_completed) + list_request = _create_request(_on_list_completed) + question_request = _create_request(_on_question_completed) + retry_timer = Timer.new() + retry_timer.one_shot = true + retry_timer.wait_time = RETRY_DELAY_SECONDS + retry_timer.timeout.connect(_process_queue) + add_child(retry_timer) + _load_queue() + auth_state.auth_changed.connect(_on_auth_changed) + auth_state.session_refreshed.connect(_process_queue) + _on_auth_changed() + + +func _create_request(callback: Callable) -> HTTPRequest: + var result := HTTPRequest.new() + result.timeout = REQUEST_TIMEOUT_SECONDS + result.request_completed.connect(callback) + add_child(result) + return result + + +func observe_question(question: Dictionary) -> void: + var question_id := str(question.get("id", "")) + if question_id == "": + return + if not bookmarked.has(question_id): + bookmarked[question_id] = bool(question.get("is_bookmarked", false)) + _apply_pending_state(question_id) + + +func is_bookmarked(question_id: String) -> bool: + return bool(bookmarked.get(question_id, false)) + + +func is_pending(question_id: String) -> bool: + for operation in pending_operations: + if str(operation.get("account", "")) == active_account_key and str(operation.get("question_id", "")) == question_id: + return true + return false + + +func toggle(question_id: String) -> void: + set_bookmarked(question_id, not is_bookmarked(question_id)) + + +func toggle_question(question: Dictionary) -> void: + var question_id := str(question.get("id", "")) + set_bookmarked(question_id, not is_bookmarked(question_id), _summary_from_question(question)) + + +func set_bookmarked(question_id: String, value: bool, summary: Dictionary = {}) -> void: + if active_account_key == "" or question_id == "": + return + bookmarked[question_id] = value + var replaced := false + for index in range(pending_operations.size()): + var operation: Dictionary = pending_operations[index] + if str(operation.get("account", "")) == active_account_key and str(operation.get("question_id", "")) == question_id: + if index == active_operation_index: + continue + operation["value"] = value + operation["retry_count"] = 0 + if value and not summary.is_empty(): + operation["summary"] = summary.duplicate(true) + pending_operations[index] = operation + replaced = true + break + if not replaced: + pending_operations.append({ + "account": active_account_key, + "question_id": question_id, + "value": value, + "retry_count": 0, + "summary": summary.duplicate(true) + }) + _save_queue() + status_changed.emit(question_id, value, true) + if not value: + _remove_list_item(question_id) + elif not summary.is_empty(): + _upsert_local_item(summary) + _process_queue() + + +func load_bookmarks(reset: bool = true, filters: Dictionary = {}) -> void: + if list_loading: + if reset: + pending_list_reload = true + pending_list_filters = filters.duplicate(true) + return + if reset: + items.clear() + next_cursor = "" + has_more = false + remote_list_loaded = false + for key in active_filters: + active_filters[key] = str(filters.get(key, active_filters[key])) + _restore_pending_items() + elif not has_more: + return + if not auth_state.can_send_authenticated_request(): + list_error = "Bookmarks are offline. Pending changes will sync when your connection returns." + list_failed.emit(list_error) + list_changed.emit() + return + list_loading = true + list_error = "" + var params: Array[String] = ["limit=20", "sort=%s" % str(active_filters.sort).uri_encode()] + for key in ["query", "system", "discipline"]: + var value := str(active_filters[key]).strip_edges() + if value != "": + params.append("%s=%s" % [key, value.uri_encode()]) + if not reset and next_cursor != "": + params.append("cursor=%s" % next_cursor.uri_encode()) + var error := list_request.request("%s?%s" % [api_url, "&".join(params)], _headers(), HTTPClient.METHOD_GET) + if error != OK: + list_loading = false + list_error = "Bookmarks are unavailable. Check your connection and retry." + list_failed.emit(list_error) + + +func load_question(question_id: String) -> void: + if question_id == "" or not auth_state.can_send_authenticated_request(): + question_failed.emit("The bookmarked question could not be loaded.") + return + if question_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + requested_question_id = question_id + var url := "%s/%s/question" % [api_url, question_id.uri_encode()] + var error := question_request.request(url, _headers(), HTTPClient.METHOD_GET) + if error != OK: + question_failed.emit("The bookmarked question could not be loaded.") + + +func question_ids() -> Array: + return items.map(func(item: Dictionary): return str(item.get("question_id", ""))) + + +func begin_practice(question_id: String) -> void: + practice_ids = question_ids() + practice_index = practice_ids.find(question_id) + if practice_index < 0: + practice_ids = [question_id] + practice_index = 0 + load_question(question_id) + + +func has_next_practice() -> bool: + return practice_index + 1 < practice_ids.size() or has_more + + +func next_practice() -> void: + if practice_index + 1 < practice_ids.size(): + practice_index += 1 + load_question(str(practice_ids[practice_index])) + return + if has_more: + practice_waiting_for_page = true + load_bookmarks(false) + return + question_failed.emit("You have reached the end of this bookmark list.") + + +func _on_auth_changed() -> void: + if mutation_request != null and mutation_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + mutation_request.cancel_request() + active_operation_index = -1 + if not auth_state.is_logged_in: + active_account_key = "" + bookmarked.clear() + items.clear() + next_cursor = "" + has_more = false + remote_list_loaded = false + return + active_account_key = auth_state.username.strip_edges().to_lower().sha256_text() + bookmarked.clear() + items.clear() + next_cursor = "" + has_more = false + remote_list_loaded = false + available_systems.clear() + available_disciplines.clear() + _apply_all_pending_states() + _restore_pending_items() + _process_queue() + + +func _process_queue() -> void: + if active_operation_index >= 0 or not auth_state.can_send_authenticated_request() or active_account_key == "": + return + for index in range(pending_operations.size()): + if str(pending_operations[index].get("account", "")) == active_account_key: + active_operation_index = index + break + if active_operation_index < 0: + return + var operation: Dictionary = pending_operations[active_operation_index] + var question_id := str(operation.get("question_id", "")) + var method := HTTPClient.METHOD_PUT if bool(operation.get("value", false)) else HTTPClient.METHOD_DELETE + mutation_token = auth_state.access_token + var error := mutation_request.request("%s/%s" % [api_url, question_id.uri_encode()], _headers(), method, "") + if error != OK: + active_operation_index = -1 + retry_timer.start() + + +func _on_mutation_completed(result: int, response_code: int, _response_headers: PackedStringArray, _body: PackedByteArray) -> void: + if active_operation_index < 0 or active_operation_index >= pending_operations.size(): + active_operation_index = -1 + return + var operation: Dictionary = pending_operations[active_operation_index] + var question_id := str(operation.get("question_id", "")) + if response_code == 401: + auth_state.handle_unauthorized(mutation_token) + if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300: + pending_operations.remove_at(active_operation_index) + _save_queue() + active_operation_index = -1 + status_changed.emit(question_id, is_bookmarked(question_id), is_pending(question_id)) + call_deferred("_process_queue") + return + if response_code == 404: + bookmarked[question_id] = false + pending_operations.remove_at(active_operation_index) + _save_queue() + active_operation_index = -1 + _remove_list_item(question_id) + status_changed.emit(question_id, false, false) + call_deferred("_process_queue") + return + if response_code >= 400 and response_code < 500 and response_code not in [401, 408, 429]: + bookmarked[question_id] = not bool(operation.get("value", false)) + pending_operations.remove_at(active_operation_index) + _save_queue() + active_operation_index = -1 + status_changed.emit(question_id, is_bookmarked(question_id), is_pending(question_id)) + call_deferred("_process_queue") + return + var retry_count := int(operation.get("retry_count", 0)) + 1 + operation["retry_count"] = retry_count + pending_operations[active_operation_index] = operation + active_operation_index = -1 + _save_queue() + retry_timer.start() + + +func _on_list_completed(result: int, response_code: int, _response_headers: PackedStringArray, body: PackedByteArray) -> void: + list_loading = false + if response_code == 401: + auth_state.handle_unauthorized(auth_state.access_token) + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + list_error = "Bookmarks are unavailable. Check your connection and retry." + list_failed.emit(list_error) + _run_pending_list_reload() + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + list_error = "The bookmark service returned an invalid response." + list_failed.emit(list_error) + _run_pending_list_reload() + return + var page := parsed as Dictionary + remote_list_loaded = true + for raw_item in page.get("items", []): + if typeof(raw_item) != TYPE_DICTIONARY: + continue + var item := raw_item as Dictionary + var question_id := str(item.get("questionExternalId", "")) + if question_id == "": + continue + bookmarked[question_id] = true + _apply_pending_state(question_id) + if not bool(bookmarked.get(question_id, false)): + continue + items.append({ + "question_id": question_id, + "stem": str(item.get("stem", "")), + "lead_in": str(item.get("leadIn", "")), + "system": str(item.get("system", "")), + "discipline": str(item.get("discipline", "")), + "difficulty": int(item.get("difficulty", 1)), + "has_media": bool(item.get("hasMedia", false)), + "tags": item.get("tags", []), + "created_utc": str(item.get("createdUtc", "")) + }) + next_cursor = str(page.get("nextCursor", "")) + has_more = bool(page.get("hasMore", false)) + available_systems = page.get("systems", []).duplicate() + available_disciplines = page.get("disciplines", []).duplicate() + practice_ids = question_ids() + _restore_pending_items() + list_changed.emit() + if practice_waiting_for_page: + practice_waiting_for_page = false + if practice_index + 1 < practice_ids.size(): + practice_index += 1 + load_question(str(practice_ids[practice_index])) + else: + question_failed.emit("You have reached the end of this bookmark list.") + _run_pending_list_reload() + + +func _run_pending_list_reload() -> void: + if not pending_list_reload: + return + var filters := pending_list_filters.duplicate(true) + pending_list_reload = false + pending_list_filters.clear() + call_deferred("load_bookmarks", true, filters) + + +func _on_question_completed(result: int, response_code: int, _response_headers: PackedStringArray, body: PackedByteArray) -> void: + var completed_question_id := requested_question_id + requested_question_id = "" + if response_code == 401: + auth_state.handle_unauthorized(auth_state.access_token) + if response_code == 404: + if completed_question_id != "": + bookmarked[completed_question_id] = false + _remove_list_item(completed_question_id) + status_changed.emit(completed_question_id, false, false) + question_failed.emit("This question is no longer available in your bookmarks.") + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + question_failed.emit("The bookmarked question could not be loaded.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + question_failed.emit("The bookmark service returned an invalid question.") + return + var normalized := QuestionBankService.normalize_api_question(parsed) + if normalized.is_empty(): + question_failed.emit("The bookmark service returned an invalid question.") + return + observe_question(normalized) + question_loaded.emit(normalized) + + +func _headers() -> PackedStringArray: + return PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % auth_state.access_token + ]) + + +func _apply_pending_state(question_id: String) -> void: + for operation in pending_operations: + if str(operation.get("account", "")) == active_account_key and str(operation.get("question_id", "")) == question_id: + bookmarked[question_id] = bool(operation.get("value", false)) + + +func _apply_all_pending_states() -> void: + for operation in pending_operations: + if str(operation.get("account", "")) == active_account_key: + bookmarked[str(operation.get("question_id", ""))] = bool(operation.get("value", false)) + + +func _remove_list_item(question_id: String) -> void: + var practice_position := practice_ids.find(question_id) + if practice_position >= 0: + practice_ids.remove_at(practice_position) + if practice_position <= practice_index: + practice_index -= 1 + for index in range(items.size() - 1, -1, -1): + if str(items[index].get("question_id", "")) == question_id: + items.remove_at(index) + list_changed.emit() + + +func _summary_from_question(question: Dictionary) -> Dictionary: + return { + "question_id": str(question.get("id", "")), + "stem": str(question.get("stem", "")), + "lead_in": str(question.get("lead_in", "")), + "system": str(question.get("system", "")), + "discipline": str(question.get("discipline", "")), + "difficulty": int(question.get("difficulty", 1)), + "has_media": not question.get("media", []).is_empty(), + "tags": question.get("tags", []).duplicate(), + "created_utc": "%sZ" % Time.get_datetime_string_from_system(true) + } + + +func _restore_pending_items() -> void: + for operation in pending_operations: + if str(operation.get("account", "")) != active_account_key or not bool(operation.get("value", false)): + continue + var summary: Dictionary = operation.get("summary", {}) + if not summary.is_empty(): + _upsert_local_item(summary, false) + + +func _upsert_local_item(summary: Dictionary, emit_change: bool = true) -> void: + var question_id := str(summary.get("question_id", "")) + if question_id == "" or not _summary_matches_filters(summary): + return + for existing in items: + if str(existing.get("question_id", "")) == question_id: + return + items.append(summary.duplicate(true)) + items.sort_custom(func(left: Dictionary, right: Dictionary): + var left_date := str(left.get("created_utc", "")) + var right_date := str(right.get("created_utc", "")) + return left_date < right_date if str(active_filters.sort) == "oldest" else left_date > right_date + ) + practice_ids = question_ids() + if emit_change: + list_changed.emit() + + +func _summary_matches_filters(summary: Dictionary) -> bool: + var system := str(active_filters.system).strip_edges() + if system != "" and system != str(summary.get("system", "")): + return false + var discipline := str(active_filters.discipline).strip_edges() + if discipline != "" and discipline != str(summary.get("discipline", "")): + return false + var query := str(active_filters.query).strip_edges().to_lower() + if query == "": + return true + var searchable := "%s %s %s" % [ + str(summary.get("stem", "")), str(summary.get("lead_in", "")), + " ".join(summary.get("tags", []))] + return searchable.to_lower().contains(query) + + +func _load_queue() -> void: + pending_operations = [] + if not FileAccess.file_exists(QUEUE_PATH): + return + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(QUEUE_PATH)) + if typeof(parsed) != TYPE_ARRAY: + return + for item in parsed: + if typeof(item) == TYPE_DICTIONARY and str(item.get("account", "")) != "" and str(item.get("question_id", "")) != "": + pending_operations.append(item) + + +func _save_queue() -> void: + var file := FileAccess.open(QUEUE_PATH, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(pending_operations)) diff --git a/scripts/autoload/bookmark_service.gd.uid b/scripts/autoload/bookmark_service.gd.uid new file mode 100644 index 0000000..f6fc3a4 --- /dev/null +++ b/scripts/autoload/bookmark_service.gd.uid @@ -0,0 +1 @@ +uid://l0ehkglucdl1 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index f5af178..0161d65 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -678,6 +678,7 @@ func _normalize_api_question(item: Variant) -> Dictionary: "performance_category": str(item.get("performanceCategory", item.get("performance_category", ""))), "difficulty": int(item.get("difficulty", 1)), "question_elo": int(item.get("questionElo", item.get("question_elo", 1000))), + "is_bookmarked": bool(item.get("isBookmarked", item.get("is_bookmarked", false))), "labs": item.get("labs", []), "media": item.get("media", []), "tags": item.get("tags", []), @@ -686,6 +687,10 @@ func _normalize_api_question(item: Variant) -> Dictionary: return normalized +func normalize_api_question(item: Variant) -> Dictionary: + return _normalize_api_question(item) + + func _normalize_review_followups(raw_followups: Variant) -> Array: var normalized: Array = [] if typeof(raw_followups) != TYPE_ARRAY: diff --git a/scripts/ui/bookmarks_screen.gd b/scripts/ui/bookmarks_screen.gd new file mode 100644 index 0000000..3b6cd42 --- /dev/null +++ b/scripts/ui/bookmarks_screen.gd @@ -0,0 +1,246 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var main +var search_input: LineEdit +var system_filter: OptionButton +var discipline_filter: OptionButton +var sort_filter: OptionButton +var status_label: Label +var retry_button: Button +var list_container: VBoxContainer +var load_more_button: Button +var debounce_timer: Timer +var list_scroll: ScrollContainer +var saved_scroll_position := 0 +var pending_open_question_id := "" + + +func _ready() -> void: + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 16) + _build_header() + _build_filters() + status_label = Label.new() + status_label.add_theme_font_override("font", Brand.font) + status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + add_child(status_label) + retry_button = Button.new() + retry_button.text = "Retry" + retry_button.visible = false + retry_button.custom_minimum_size = Vector2(100, 42) + retry_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + retry_button.pressed.connect(_retry) + add_child(retry_button) + list_scroll = ScrollContainer.new() + list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(list_scroll) + list_container = VBoxContainer.new() + list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + list_container.add_theme_constant_override("separation", 10) + list_scroll.add_child(list_container) + load_more_button = Button.new() + load_more_button.text = "Load more" + load_more_button.custom_minimum_size = Vector2(0, 44) + load_more_button.pressed.connect(func(): BookmarkService.load_bookmarks(false)) + list_container.add_child(load_more_button) + debounce_timer = Timer.new() + debounce_timer.one_shot = true + debounce_timer.wait_time = 0.35 + debounce_timer.timeout.connect(_reload) + add_child(debounce_timer) + BookmarkService.list_changed.connect(_render) + BookmarkService.list_failed.connect(_on_list_failed) + BookmarkService.question_loaded.connect(_on_question_loaded) + BookmarkService.question_failed.connect(_on_question_load_failed) + UIThemeService.theme_changed.connect(func(_dark: bool): _render()) + + +func _build_header() -> void: + var eyebrow := Label.new() + eyebrow.text = "STUDY TOOLS" + eyebrow.add_theme_font_override("font", Brand.font) + eyebrow.add_theme_font_size_override("font_size", 13) + eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE) + add_child(eyebrow) + var title := Label.new() + title.text = "Bookmarks" + title.add_theme_font_override("font", Brand.font) + title.add_theme_font_size_override("font_size", 32) + add_child(title) + var subtitle := Label.new() + subtitle.text = "Review saved questions without changing your rating or progression." + subtitle.add_theme_font_override("font", Brand.font) + subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + add_child(subtitle) + + +func _build_filters() -> void: + var row := HFlowContainer.new() + row.add_theme_constant_override("separation", 10) + add_child(row) + search_input = LineEdit.new() + search_input.placeholder_text = "Search saved questions" + search_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL + search_input.custom_minimum_size = Vector2(260, 44) + search_input.text_changed.connect(func(_value: String): debounce_timer.start()) + row.add_child(search_input) + system_filter = _add_filter(row, "All systems") + discipline_filter = _add_filter(row, "All disciplines") + sort_filter = _add_filter(row, "Newest", ["Oldest"]) + + +func _add_filter(parent: Container, first: String, extras: Array = []) -> OptionButton: + var control := OptionButton.new() + control.add_item(first) + for text in extras: + control.add_item(str(text)) + control.custom_minimum_size = Vector2(150, 44) + control.item_selected.connect(func(_index: int): _reload()) + parent.add_child(control) + return control + + +func on_show(_payload: Variant = null) -> void: + _populate_categories() + if not BookmarkService.remote_list_loaded: + BookmarkService.load_bookmarks(true, _filters()) + _render() + + +func on_hide() -> void: + if list_scroll != null: + saved_scroll_position = int(list_scroll.scroll_vertical) + + +func _populate_categories() -> void: + _populate_filter(system_filter, "All systems", BookmarkService.available_systems) + _populate_filter(discipline_filter, "All disciplines", BookmarkService.available_disciplines) + + +func _populate_filter(control: OptionButton, first: String, values: Array) -> void: + var selected_text := control.get_item_text(control.selected) if control.item_count > 0 else first + control.clear() + control.add_item(first) + for value in values: + if str(value) != "" and str(value) != "All Systems": + control.add_item(str(value)) + for index in range(control.item_count): + if control.get_item_text(index) == selected_text: + control.select(index) + break + + +func _filters() -> Dictionary: + return { + "query": search_input.text, + "system": "" if system_filter.selected == 0 else system_filter.get_item_text(system_filter.selected), + "discipline": "" if discipline_filter.selected == 0 else discipline_filter.get_item_text(discipline_filter.selected), + "sort": "oldest" if sort_filter.selected == 1 else "newest" + } + + +func _reload() -> void: + BookmarkService.load_bookmarks(true, _filters()) + _render() + + +func _render() -> void: + if list_container == null: + return + if not visible: + return + _populate_categories() + for child in list_container.get_children(): + if child != load_more_button: + child.queue_free() + status_label.text = "Loading bookmarks..." if BookmarkService.list_loading else BookmarkService.list_error + retry_button.visible = BookmarkService.list_error != "" + if not BookmarkService.list_loading and BookmarkService.items.is_empty() and BookmarkService.list_error == "": + status_label.text = "No saved questions match these filters." + var insert_index := 0 + for item in BookmarkService.items: + var card := _bookmark_card(item) + list_container.add_child(card) + list_container.move_child(card, insert_index) + insert_index += 1 + load_more_button.visible = BookmarkService.has_more + load_more_button.disabled = BookmarkService.list_loading + if list_scroll != null: + list_scroll.set_deferred("scroll_vertical", saved_scroll_position) + + +func _bookmark_card(item: Dictionary) -> Control: + var dark := UIThemeService.is_dark_mode() + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.TEAL)) + var row := HFlowContainer.new() + row.add_theme_constant_override("separation", 14) + card.add_child(row) + var content := VBoxContainer.new() + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(content) + var stem := Label.new() + stem.text = str(item.get("stem", "")) + stem.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + stem.add_theme_font_override("font", Brand.font) + stem.add_theme_font_size_override("font_size", 16) + content.add_child(stem) + var metadata := Label.new() + metadata.text = "%s | %s | Difficulty %d%s" % [ + str(item.get("system", "")), str(item.get("discipline", "")), + int(item.get("difficulty", 1)), " | Image" if bool(item.get("has_media", false)) else ""] + metadata.add_theme_font_override("font", Brand.font) + metadata.add_theme_font_size_override("font_size", 13) + metadata.add_theme_color_override("font_color", Brand.muted_color(dark)) + content.add_child(metadata) + var open := Button.new() + open.text = "Practice" + open.custom_minimum_size = Vector2(105, 42) + open.pressed.connect(_open_question.bind(str(item.get("question_id", "")))) + row.add_child(open) + var remove := Button.new() + remove.icon = preload("res://assets/icons/lucide/bookmark.svg") + remove.tooltip_text = "Remove bookmark" + remove.custom_minimum_size = Vector2(44, 42) + remove.pressed.connect(BookmarkService.set_bookmarked.bind(str(item.get("question_id", "")), false)) + row.add_child(remove) + return card + + +func _open_question(question_id: String) -> void: + pending_open_question_id = question_id + status_label.text = "Loading question..." + retry_button.visible = false + BookmarkService.begin_practice(question_id) + + +func _on_question_loaded(question: Dictionary) -> void: + if not visible: + return + pending_open_question_id = "" + if BlockSessionService.start_bookmark_question(question): + main.show_screen("player") + + +func _on_list_failed(message: String) -> void: + status_label.text = message + retry_button.visible = true + + +func _on_question_load_failed(message: String) -> void: + if not visible: + return + status_label.text = message + retry_button.visible = true + + +func _retry() -> void: + retry_button.visible = false + if pending_open_question_id != "": + status_label.text = "Loading question..." + BookmarkService.begin_practice(pending_open_question_id) + else: + _reload() diff --git a/scripts/ui/bookmarks_screen.gd.uid b/scripts/ui/bookmarks_screen.gd.uid new file mode 100644 index 0000000..a52e99e --- /dev/null +++ b/scripts/ui/bookmarks_screen.gd.uid @@ -0,0 +1 @@ +uid://k7ycreyxpntj diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 842132f..159e9d9 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -289,6 +289,11 @@ func _build_ui() -> void: notes.main = self screens_container.add_child(notes) screens["notes"] = notes + var bookmarks := preload("res://scripts/ui/bookmarks_screen.gd").new() + bookmarks.name = "BookmarksScreen" + bookmarks.main = self + screens_container.add_child(bookmarks) + screens["bookmarks"] = bookmarks feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new() feedback_dialog.name = "AppFeedbackDialog" @@ -357,7 +362,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void _add_nav_button(container, "performance", "Performance", "chart-no-axes-column-increasing", true) _add_nav_button(container, "flashcards", "Flashcards", "copy", false) _add_nav_button(container, "study_plan", "Study Plan", "calendar-days", true) - _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", false) + _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true) var spacer := Control.new() @@ -402,6 +407,7 @@ func _on_navigation_pressed(key: String) -> void: "study_plan": show_screen("map") "settings": show_screen("settings") "notes": show_screen("notes") + "bookmarks": show_screen("bookmarks") "logout": _logout() diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index ce58cff..7de0569 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -19,4 +19,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri key = "settings" elif screen_name == "notes": key = "notes" + elif screen_name == "bookmarks": + key = "bookmarks" return key diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index e5f4789..8f472e1 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -84,6 +84,7 @@ var clear_highlight_button: Button var flag_button: Button var report_button: Button var notes_button: Button +var bookmark_button: Button var app_feedback_button: Button var theme_button: Button var question_toolbar: HFlowContainer @@ -161,6 +162,12 @@ func _ready() -> void: ExamSimulationService.state_changed.connect(_on_simulation_state_changed) if not ExamSimulationService.request_failed.is_connected(_on_simulation_request_failed): ExamSimulationService.request_failed.connect(_on_simulation_request_failed) + if not BookmarkService.status_changed.is_connected(_on_bookmark_status_changed): + BookmarkService.status_changed.connect(_on_bookmark_status_changed) + if not BookmarkService.question_loaded.is_connected(_on_bookmark_question_loaded): + BookmarkService.question_loaded.connect(_on_bookmark_question_loaded) + if not BookmarkService.question_failed.is_connected(_on_bookmark_question_failed): + BookmarkService.question_failed.connect(_on_bookmark_question_failed) question_card = PanelContainer.new() question_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -267,6 +274,18 @@ func _ready() -> void: ) question_toolbar.add_child(flag_button) + bookmark_button = Button.new() + bookmark_button.tooltip_text = "Save question" + bookmark_button.icon = preload("res://assets/icons/lucide/bookmark.svg") + bookmark_button.custom_minimum_size = Vector2(44, 44) + bookmark_button.add_theme_constant_override("icon_max_width", 22) + bookmark_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + bookmark_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + bookmark_button.focus_mode = Control.FOCUS_NONE + UISoundService.set_button_sound(bookmark_button, "toggle") + bookmark_button.pressed.connect(_toggle_bookmark_current_question) + question_toolbar.add_child(bookmark_button) + report_button = Button.new() report_button.tooltip_text = "Report question" report_button.custom_minimum_size = Vector2(44, 44) @@ -840,10 +859,10 @@ func _apply_question_theme() -> void: var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label if navigator_heading != null: navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark)) - for control in [text_smaller_button, text_larger_button, flag_button, notes_button, app_feedback_button]: + for control in [text_smaller_button, text_larger_button, flag_button, bookmark_button, notes_button, app_feedback_button]: if control == null: continue - var active: bool = control == flag_button and flagged + var active: bool = (control == flag_button and flagged) or (control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))) control.add_theme_stylebox_override("normal", _toolbar_box(dark, active)) control.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) control.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) @@ -1018,15 +1037,17 @@ func _load_question() -> void: next_button.disabled = true skip_button.disabled = false skip_button.visible = true + skip_button.text = "Skip" report_button.visible = BlockSessionService.session_kind != "simulation" var question := BlockSessionService.get_current_question() if question.is_empty(): - main.show_screen("selection", "exam" if BlockSessionService.session_kind == "core" else "step3") + main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3") return + BookmarkService.observe_question(question) progress_button.text = _question_progress_text() - progress_button.disabled = BlockSessionService.session_kind == "core" - progress_button.tooltip_text = "" if BlockSessionService.session_kind == "core" else "Open question navigator" + progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark"] + progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark"] else "Open question navigator" var show_timer := _is_session_timer_visible() timer_label.visible = show_timer timer_bar.visible = show_timer @@ -1150,6 +1171,16 @@ func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> b func _skip_current_question() -> void: + if BlockSessionService.session_kind == "bookmark": + if not current_answer.is_empty(): + _finish() + return + current_answer = BlockSessionService.skip_bookmark_question(eliminated, flagged) + if current_answer.is_empty(): + return + selected_choice = "" + _show_answer_review() + return if pending_correct_advance or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation"): return if BlockSessionService.session_kind != "core": @@ -1260,15 +1291,16 @@ func _submit_or_next() -> void: if selected_choice == "": return var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", "")) - if BlockSessionService.session_kind == "core": + if BlockSessionService.session_kind in ["core", "bookmark"]: _collapse_resolved_followups() var attempted_choice := selected_choice - QuestionBankService.record_first_attempt( - question_id_for_attempt, - attempted_choice, - _presented_choice_ids() - ) - var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) + if BlockSessionService.session_kind == "core": + QuestionBankService.record_first_attempt( + question_id_for_attempt, + attempted_choice, + _presented_choice_ids() + ) + var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) if BlockSessionService.session_kind == "core" else BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged) if result.is_empty(): return var answer: Dictionary = result.get("answer", {}) @@ -1297,6 +1329,14 @@ func _submit_or_next() -> void: else: if BlockSessionService.session_kind == "core": _request_next_core_question() + elif BlockSessionService.session_kind == "bookmark": + if BookmarkService.has_next_practice(): + awaiting_next_question = true + next_button.text = "Loading..." + next_button.disabled = true + BookmarkService.next_practice() + else: + _finish() else: _advance_or_finish() @@ -1345,9 +1385,14 @@ func _show_answer_review() -> void: _set_answer_review_timer_visibility() next_button.text = "Next" next_button.disabled = false + if BlockSessionService.session_kind == "bookmark" and not BookmarkService.has_next_practice(): + next_button.text = "End of list" + next_button.disabled = true if skip_button != null: skip_button.disabled = true - skip_button.visible = false + skip_button.visible = BlockSessionService.session_kind == "bookmark" + skip_button.text = "Back to Bookmarks" if BlockSessionService.session_kind == "bookmark" else "Skip" + skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true if question_scroll != null: question_scroll.call_deferred("ensure_control_visible", explanation_label) @@ -1653,6 +1698,10 @@ func _advance_or_finish() -> void: func _finish() -> void: + if BlockSessionService.session_kind == "bookmark": + BlockSessionService.finish_block() + main.show_screen("bookmarks") + return if BlockSessionService.session_kind == "core": BlockSessionService.finish_block() main.show_screen("selection", "exam") @@ -1662,6 +1711,10 @@ func _finish() -> void: func _exit_run() -> void: + if BlockSessionService.session_kind == "bookmark": + BlockSessionService.finish_block() + main.show_screen("bookmarks") + return var was_core_run := BlockSessionService.session_kind == "core" BlockSessionService.resume_current_question_timer() BlockSessionService.finish_block() @@ -1879,9 +1932,45 @@ func _refresh_toolbar_state() -> void: report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" if notes_button != null: notes_button.visible = BlockSessionService.session_kind != "simulation" + if bookmark_button != null: + bookmark_button.visible = BlockSessionService.session_kind != "simulation" + var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", "")) + var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id) + var bookmark_pending := BookmarkService.is_pending(bookmark_question_id) + bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question") _apply_question_theme() +func _toggle_bookmark_current_question() -> void: + var question := BlockSessionService.get_current_question() + if not question.is_empty(): + BookmarkService.toggle_question(question) + + +func _on_bookmark_status_changed(question_id: String, value: bool, pending: bool) -> void: + if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null: + return + bookmark_button.tooltip_text = ("Saved; waiting to sync" if pending else "Remove bookmark") if value else ("Removal waiting to sync" if pending else "Save question") + _apply_question_theme() + + +func _on_bookmark_question_loaded(question: Dictionary) -> void: + if not visible or BlockSessionService.session_kind != "bookmark" or not awaiting_next_question: + return + awaiting_next_question = false + if BlockSessionService.replace_bookmark_question(question): + _load_question() + + +func _on_bookmark_question_failed(message: String) -> void: + if not visible or BlockSessionService.session_kind != "bookmark": + return + awaiting_next_question = false + next_button.text = "Retry" + next_button.disabled = false + next_button.tooltip_text = message + + func refresh_theme_controls() -> void: if media_viewer_background != null: media_viewer_background.color = Style.BACKGROUND diff --git a/tests/bookmark_session_test.gd b/tests/bookmark_session_test.gd new file mode 100644 index 0000000..8d24a81 --- /dev/null +++ b/tests/bookmark_session_test.gd @@ -0,0 +1,38 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + var service = load("res://scripts/autoload/block_session_service.gd").new() + var question := { + "id": "bookmark-test-question", + "system": "Test System", + "choices": [ + {"id": "A", "text": "Correct"}, + {"id": "B", "text": "Wrong"}, + {"id": "C", "text": "Wrong"}, + {"id": "D", "text": "Wrong"}, + {"id": "E", "text": "Wrong"} + ], + "correct_choice_id": "A" + } + _assert(service.start_bookmark_question(question), "bookmark session should start") + _assert(service.session_kind == "bookmark", "bookmark session should have its own kind") + var wrong: Dictionary = service.submit_bookmark_attempt("B", [], false) + _assert(not bool(wrong.get("is_correct", true)), "wrong bookmark attempts should remain retryable") + _assert(service.get_answer_for_index(0) == null, "wrong bookmark attempts should not finalize the question") + var correct: Dictionary = service.submit_bookmark_attempt("A", [], false) + _assert(bool(correct.get("is_correct", false)), "correct bookmark attempt should resolve") + _assert(service.get_answer_for_index(0) != null, "correct bookmark attempt should be retained for review") + var result: Dictionary = service.finish_block() + _assert(result.is_empty(), "bookmark sessions should not calculate progression rewards") + service.free() + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/bookmark_session_test.gd.uid b/tests/bookmark_session_test.gd.uid new file mode 100644 index 0000000..b4cff5a --- /dev/null +++ b/tests/bookmark_session_test.gd.uid @@ -0,0 +1 @@ +uid://osdlvlflilgk diff --git a/tests/shell_navigation_test.gd b/tests/shell_navigation_test.gd index 32adc69..b57bf81 100644 --- a/tests/shell_navigation_test.gd +++ b/tests/shell_navigation_test.gd @@ -17,6 +17,7 @@ func _initialize() -> void: _assert(NavigationRoutes.key_for_screen("map") == "study_plan", "study map should activate Study Plan") _assert(NavigationRoutes.key_for_screen("settings") == "settings", "settings should activate Settings") _assert(NavigationRoutes.key_for_screen("notes") == "notes", "notes should activate Notes") + _assert(NavigationRoutes.key_for_screen("bookmarks") == "bookmarks", "bookmarks should activate Bookmarks") quit(1 if failures > 0 else 0)