Add synced question bookmarks
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<ActionResult<QuestionBookmarkPageResponse>> 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<ActionResult<QuestionBookmarkMutationResponse>> 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<ActionResult<QuestionBookmarkMutationResponse>> 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<ActionResult<QuestionResponse>> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<IActionResult> RecordFirstAttempt([FromBody] FirstQuestionAttemptRequest req)
|
||||
|
||||
@@ -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<string> Tags { get; set; } = [];
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class QuestionBookmarkPageResponse
|
||||
{
|
||||
public List<QuestionBookmarkSummary> Items { get; set; } = [];
|
||||
public string? NextCursor { get; set; }
|
||||
public bool HasMore { get; set; }
|
||||
public List<string> Systems { get; set; } = [];
|
||||
public List<string> Disciplines { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class QuestionBookmarkMutationResponse
|
||||
{
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public bool IsBookmarked { get; set; }
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class QuestionResponse
|
||||
public List<string> Media { get; set; } = [];
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public List<QuestionReference> References { get; set; } = [];
|
||||
public bool IsBookmarked { get; set; }
|
||||
|
||||
public static QuestionResponse FromModel(
|
||||
Question question,
|
||||
|
||||
@@ -22,6 +22,7 @@ builder.Services.AddSingleton<CcsRatingStore>();
|
||||
builder.Services.AddSingleton<CcsCaseReportStore>();
|
||||
builder.Services.AddSingleton<AppFeedbackStore>();
|
||||
builder.Services.AddSingleton<UserNotepadStore>();
|
||||
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
|
||||
@@ -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<QuestionBookmark> _bookmarks;
|
||||
|
||||
public QuestionBookmarkStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
|
||||
_bookmarks.Indexes.CreateMany([
|
||||
new CreateIndexModel<QuestionBookmark>(
|
||||
Builders<QuestionBookmark>.IndexKeys
|
||||
.Ascending(item => item.UserId)
|
||||
.Ascending(item => item.QuestionExternalId),
|
||||
new CreateIndexOptions { Unique = true, Name = "bookmark_unique_user_question" }),
|
||||
new CreateIndexModel<QuestionBookmark>(
|
||||
Builders<QuestionBookmark>.IndexKeys
|
||||
.Ascending(item => item.UserId)
|
||||
.Descending(item => item.CreatedUtc)
|
||||
.Descending(item => item.Id),
|
||||
new CreateIndexOptions { Name = "bookmark_user_created_desc" })
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<QuestionBookmark> AddAsync(string userId, string externalId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
return await _bookmarks.FindOneAndUpdateAsync(
|
||||
item => item.UserId == userId && item.QuestionExternalId == externalId,
|
||||
Builders<QuestionBookmark>.Update
|
||||
.SetOnInsert(item => item.UserId, userId)
|
||||
.SetOnInsert(item => item.QuestionExternalId, externalId)
|
||||
.SetOnInsert(item => item.CreatedUtc, now)
|
||||
.Set(item => item.UpdatedUtc, now),
|
||||
new FindOneAndUpdateOptions<QuestionBookmark>
|
||||
{
|
||||
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<bool> IsBookmarkedAsync(string userId, string externalId) =>
|
||||
await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId).AnyAsync();
|
||||
|
||||
public async Task<HashSet<string>> GetBookmarkedIdsAsync(string userId, IEnumerable<string> 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<BsonDocument>
|
||||
{
|
||||
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<BsonDocument>(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<QuestionBookmark>(bookmarkDoc),
|
||||
BsonSerializer.Deserialize<Question>(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<string> Systems, List<string> Disciplines)> GetFacetsAsync(
|
||||
string userId,
|
||||
bool includeUnpublished)
|
||||
{
|
||||
var pipeline = new List<BsonDocument>
|
||||
{
|
||||
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<BsonDocument>(pipeline).FirstOrDefaultAsync();
|
||||
if (result is null) return ([], []);
|
||||
static List<string> 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<CursorPayload>(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);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://l0ehkglucdl1
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://k7ycreyxpntj
|
||||
+7
-1
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+102
-13
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://osdlvlflilgk
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user