Add synced user notes
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# Godot 4+ specific ignores
|
# Godot 4+ specific ignores
|
||||||
.godot/
|
.godot/
|
||||||
/android/
|
/android/
|
||||||
|
/build/
|
||||||
|
|
||||||
# .NET build output
|
# .NET build output
|
||||||
**/bin/
|
**/bin/
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using QuestionsApi.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Tests;
|
||||||
|
|
||||||
|
public sealed class UserNotepadTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorAcceptsPlainTextAtMaximumLength()
|
||||||
|
{
|
||||||
|
var request = new UpdateUserNotepadRequest
|
||||||
|
{
|
||||||
|
Content = new string('x', UserNotepadValidator.MaxContentLength),
|
||||||
|
ExpectedRevision = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(UserNotepadValidator.Validate(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorRejectsOversizedContentAndNegativeRevision()
|
||||||
|
{
|
||||||
|
var oversized = new UpdateUserNotepadRequest
|
||||||
|
{
|
||||||
|
Content = new string('x', UserNotepadValidator.MaxContentLength + 1),
|
||||||
|
ExpectedRevision = 0
|
||||||
|
};
|
||||||
|
Assert.Contains("characters or fewer", UserNotepadValidator.Validate(oversized));
|
||||||
|
|
||||||
|
var invalidRevision = new UpdateUserNotepadRequest { ExpectedRevision = -1 };
|
||||||
|
Assert.Contains("cannot be negative", UserNotepadValidator.Validate(invalidRevision));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResponseDoesNotExposeUserOrMongoIdentifiers()
|
||||||
|
{
|
||||||
|
var response = UserNotepadResponse.FromModel(new UserNotepad
|
||||||
|
{
|
||||||
|
UserId = "private-user-id",
|
||||||
|
Content = "My notes",
|
||||||
|
Revision = 7
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("My notes", response.Content);
|
||||||
|
Assert.Equal(7, response.Revision);
|
||||||
|
Assert.Null(typeof(UserNotepadResponse).GetProperty(nameof(UserNotepad.UserId)));
|
||||||
|
Assert.Null(typeof(UserNotepadResponse).GetProperty(nameof(UserNotepad.Id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
using QuestionsApi.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController, Route("api/notes"), Authorize(Roles = "USER,SUPER")]
|
||||||
|
public sealed class NotesController(UserNotepadStore notes) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<UserNotepadResponse>> Get()
|
||||||
|
{
|
||||||
|
var note = await notes.GetAsync(UserId());
|
||||||
|
return Ok(note is null ? UserNotepadResponse.Empty() : UserNotepadResponse.FromModel(note));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
public async Task<ActionResult<UserNotepadResponse>> Update(UpdateUserNotepadRequest request)
|
||||||
|
{
|
||||||
|
if (UserNotepadValidator.Validate(request) is { } error) return BadRequest(error);
|
||||||
|
var result = await notes.UpdateAsync(UserId(), request.Content ?? "", request.ExpectedRevision);
|
||||||
|
if (result.Succeeded) return Ok(UserNotepadResponse.FromModel(result.Note!));
|
||||||
|
var current = result.Current is null
|
||||||
|
? UserNotepadResponse.Empty()
|
||||||
|
: UserNotepadResponse.FromModel(result.Current);
|
||||||
|
return Conflict(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.Name)
|
||||||
|
?? User.Identity?.Name
|
||||||
|
?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Models;
|
||||||
|
|
||||||
|
public sealed class UserNotepad
|
||||||
|
{
|
||||||
|
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public string Content { get; set; } = "";
|
||||||
|
public long Revision { get; set; }
|
||||||
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateUserNotepadRequest
|
||||||
|
{
|
||||||
|
public string Content { get; set; } = "";
|
||||||
|
public long ExpectedRevision { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserNotepadResponse
|
||||||
|
{
|
||||||
|
public string Content { get; set; } = "";
|
||||||
|
public long Revision { get; set; }
|
||||||
|
public DateTime? UpdatedUtc { get; set; }
|
||||||
|
|
||||||
|
public static UserNotepadResponse Empty() => new();
|
||||||
|
|
||||||
|
public static UserNotepadResponse FromModel(UserNotepad note) => new()
|
||||||
|
{
|
||||||
|
Content = note.Content,
|
||||||
|
Revision = note.Revision,
|
||||||
|
UpdatedUtc = note.UpdatedUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UserNotepadValidator
|
||||||
|
{
|
||||||
|
public const int MaxContentLength = 50_000;
|
||||||
|
|
||||||
|
public static string? Validate(UpdateUserNotepadRequest request)
|
||||||
|
{
|
||||||
|
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
||||||
|
if ((request.Content ?? "").Length > MaxContentLength)
|
||||||
|
return $"content must be {MaxContentLength} characters or fewer";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ builder.Services.AddSingleton<CcsStore>();
|
|||||||
builder.Services.AddSingleton<CcsRatingStore>();
|
builder.Services.AddSingleton<CcsRatingStore>();
|
||||||
builder.Services.AddSingleton<CcsCaseReportStore>();
|
builder.Services.AddSingleton<CcsCaseReportStore>();
|
||||||
builder.Services.AddSingleton<AppFeedbackStore>();
|
builder.Services.AddSingleton<AppFeedbackStore>();
|
||||||
|
builder.Services.AddSingleton<UserNotepadStore>();
|
||||||
builder.Services.AddSingleton<ServiceMetrics>();
|
builder.Services.AddSingleton<ServiceMetrics>();
|
||||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
|
public sealed record UserNotepadUpdateResult(UserNotepad? Note, UserNotepad? Current)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Note is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserNotepadStore
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<UserNotepad> _notes;
|
||||||
|
|
||||||
|
public UserNotepadStore(MongoDatabaseProvider mongo)
|
||||||
|
{
|
||||||
|
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||||
|
_notes.Indexes.CreateOne(new CreateIndexModel<UserNotepad>(
|
||||||
|
Builders<UserNotepad>.IndexKeys.Ascending(note => note.UserId),
|
||||||
|
new CreateIndexOptions { Unique = true, Name = "user_notepad_unique_user" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserNotepad?> GetAsync(string userId) =>
|
||||||
|
await _notes.Find(note => note.UserId == userId).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<UserNotepadUpdateResult> UpdateAsync(
|
||||||
|
string userId,
|
||||||
|
string content,
|
||||||
|
long expectedRevision)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (expectedRevision == 0)
|
||||||
|
{
|
||||||
|
var created = new UserNotepad
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Content = content,
|
||||||
|
Revision = 1,
|
||||||
|
CreatedUtc = now,
|
||||||
|
UpdatedUtc = now
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _notes.InsertOneAsync(created);
|
||||||
|
return new UserNotepadUpdateResult(created, null);
|
||||||
|
}
|
||||||
|
catch (MongoWriteException exception) when (
|
||||||
|
exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||||
|
{
|
||||||
|
return new UserNotepadUpdateResult(null, await GetAsync(userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = await _notes.FindOneAndUpdateAsync(
|
||||||
|
note => note.UserId == userId && note.Revision == expectedRevision,
|
||||||
|
Builders<UserNotepad>.Update
|
||||||
|
.Set(note => note.Content, content)
|
||||||
|
.Set(note => note.UpdatedUtc, now)
|
||||||
|
.Inc(note => note.Revision, 1),
|
||||||
|
new FindOneAndUpdateOptions<UserNotepad> { ReturnDocument = ReturnDocument.After });
|
||||||
|
|
||||||
|
return updated is not null
|
||||||
|
? new UserNotepadUpdateResult(updated, null)
|
||||||
|
: new UserNotepadUpdateResult(null, await GetAsync(userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ BlockSessionService="*res://scripts/autoload/block_session_service.gd"
|
|||||||
ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd"
|
ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd"
|
||||||
CCSService="*res://scripts/autoload/ccs_service.gd"
|
CCSService="*res://scripts/autoload/ccs_service.gd"
|
||||||
FeedbackService="*res://scripts/autoload/feedback_service.gd"
|
FeedbackService="*res://scripts/autoload/feedback_service.gd"
|
||||||
|
NotesService="*res://scripts/autoload/notes_service.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
@@ -52,4 +53,5 @@ textures/vram_compression/import_etc2_astc=true
|
|||||||
questions_api_url="https://s3q.ranaze.com/api/questions/search"
|
questions_api_url="https://s3q.ranaze.com/api/questions/search"
|
||||||
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
|
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
|
||||||
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
|
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
|
||||||
|
notes_api_url="https://s3q.ranaze.com/api/notes"
|
||||||
build_revision="development"
|
build_revision="development"
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||||
|
const DEFAULT_API_URL := "https://s3q.ranaze.com/api/notes"
|
||||||
|
const REQUEST_TIMEOUT_SECONDS := 30.0
|
||||||
|
const AUTOSAVE_DELAY_SECONDS := 1.5
|
||||||
|
const RETRY_DELAY_SECONDS := 5.0
|
||||||
|
const MAX_CONTENT_LENGTH := 50000
|
||||||
|
|
||||||
|
signal content_changed(content: String)
|
||||||
|
signal state_changed(state: String)
|
||||||
|
signal conflict_detected(server_content: String)
|
||||||
|
|
||||||
|
var api_url := DEFAULT_API_URL
|
||||||
|
var content := ""
|
||||||
|
var revision := 0
|
||||||
|
var updated_utc := ""
|
||||||
|
var state := "idle"
|
||||||
|
var dirty := false
|
||||||
|
var conflict_server_content := ""
|
||||||
|
var conflict_server_revision := 0
|
||||||
|
var conflict_active := false
|
||||||
|
|
||||||
|
var request: HTTPRequest
|
||||||
|
var autosave_timer: Timer
|
||||||
|
var retry_timer: Timer
|
||||||
|
var request_action := ""
|
||||||
|
var request_access_token := ""
|
||||||
|
var request_account_key := ""
|
||||||
|
var sent_content := ""
|
||||||
|
var active_account_key := ""
|
||||||
|
var remote_loaded := false
|
||||||
|
var auth_state: Node
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
auth_state = get_node("/root/AuthState")
|
||||||
|
api_url = EndpointConfig.resolve(
|
||||||
|
str(ProjectSettings.get_setting("step3/notes_api_url", DEFAULT_API_URL)),
|
||||||
|
"/api/notes")
|
||||||
|
request = HTTPRequest.new()
|
||||||
|
request.timeout = REQUEST_TIMEOUT_SECONDS
|
||||||
|
request.request_completed.connect(_on_request_completed)
|
||||||
|
add_child(request)
|
||||||
|
autosave_timer = Timer.new()
|
||||||
|
autosave_timer.one_shot = true
|
||||||
|
autosave_timer.wait_time = AUTOSAVE_DELAY_SECONDS
|
||||||
|
autosave_timer.timeout.connect(_save_remote)
|
||||||
|
add_child(autosave_timer)
|
||||||
|
retry_timer = Timer.new()
|
||||||
|
retry_timer.one_shot = true
|
||||||
|
retry_timer.wait_time = RETRY_DELAY_SECONDS
|
||||||
|
retry_timer.timeout.connect(_retry_sync)
|
||||||
|
add_child(retry_timer)
|
||||||
|
auth_state.auth_changed.connect(_on_auth_changed)
|
||||||
|
auth_state.session_refreshed.connect(_on_session_refreshed)
|
||||||
|
_on_auth_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func ensure_loaded() -> void:
|
||||||
|
if not auth_state.is_logged_in:
|
||||||
|
return
|
||||||
|
var account_key := _account_key(auth_state.username)
|
||||||
|
if account_key != active_account_key:
|
||||||
|
_activate_account(account_key)
|
||||||
|
if not remote_loaded and request_action.is_empty():
|
||||||
|
_load_remote()
|
||||||
|
|
||||||
|
|
||||||
|
func set_content(value: String) -> void:
|
||||||
|
if value.length() > MAX_CONTENT_LENGTH:
|
||||||
|
value = value.left(MAX_CONTENT_LENGTH)
|
||||||
|
if value == content:
|
||||||
|
return
|
||||||
|
content = value
|
||||||
|
dirty = true
|
||||||
|
_clear_conflict()
|
||||||
|
_write_local_draft()
|
||||||
|
_set_state("dirty")
|
||||||
|
content_changed.emit(content)
|
||||||
|
autosave_timer.start()
|
||||||
|
|
||||||
|
|
||||||
|
func flush() -> void:
|
||||||
|
if dirty and request_action.is_empty() and not conflict_active:
|
||||||
|
autosave_timer.stop()
|
||||||
|
_save_remote()
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_conflict_keep_draft() -> void:
|
||||||
|
if not conflict_active:
|
||||||
|
return
|
||||||
|
revision = conflict_server_revision
|
||||||
|
_clear_conflict()
|
||||||
|
dirty = true
|
||||||
|
_write_local_draft()
|
||||||
|
_set_state("dirty")
|
||||||
|
autosave_timer.start()
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_conflict_use_synced() -> void:
|
||||||
|
if not conflict_active:
|
||||||
|
return
|
||||||
|
content = conflict_server_content
|
||||||
|
revision = conflict_server_revision
|
||||||
|
_clear_conflict()
|
||||||
|
dirty = false
|
||||||
|
_write_local_draft()
|
||||||
|
_set_state("saved")
|
||||||
|
content_changed.emit(content)
|
||||||
|
|
||||||
|
|
||||||
|
func status_text() -> String:
|
||||||
|
match state:
|
||||||
|
"loading": return "Loading..."
|
||||||
|
"dirty": return "Unsaved changes"
|
||||||
|
"saving": return "Saving..."
|
||||||
|
"saved": return "Saved"
|
||||||
|
"offline": return "Saved locally - waiting for connection"
|
||||||
|
"conflict": return "Changes found on another device"
|
||||||
|
_: return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _on_auth_changed() -> void:
|
||||||
|
if not auth_state.is_logged_in:
|
||||||
|
_deactivate_account()
|
||||||
|
return
|
||||||
|
var account_key := _account_key(auth_state.username)
|
||||||
|
if account_key != active_account_key:
|
||||||
|
_activate_account(account_key)
|
||||||
|
elif dirty and request_action.is_empty() and not conflict_active:
|
||||||
|
retry_timer.start()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_session_refreshed() -> void:
|
||||||
|
if dirty and request_action.is_empty() and not conflict_active:
|
||||||
|
_save_remote()
|
||||||
|
|
||||||
|
|
||||||
|
func _activate_account(account_key: String) -> void:
|
||||||
|
_cancel_request()
|
||||||
|
autosave_timer.stop()
|
||||||
|
retry_timer.stop()
|
||||||
|
active_account_key = account_key
|
||||||
|
content = ""
|
||||||
|
revision = 0
|
||||||
|
updated_utc = ""
|
||||||
|
dirty = false
|
||||||
|
remote_loaded = false
|
||||||
|
_clear_conflict()
|
||||||
|
_read_local_draft()
|
||||||
|
content_changed.emit(content)
|
||||||
|
_load_remote()
|
||||||
|
|
||||||
|
|
||||||
|
func _deactivate_account() -> void:
|
||||||
|
_cancel_request()
|
||||||
|
if dirty:
|
||||||
|
_write_local_draft()
|
||||||
|
autosave_timer.stop()
|
||||||
|
retry_timer.stop()
|
||||||
|
active_account_key = ""
|
||||||
|
content = ""
|
||||||
|
revision = 0
|
||||||
|
updated_utc = ""
|
||||||
|
dirty = false
|
||||||
|
remote_loaded = false
|
||||||
|
_clear_conflict()
|
||||||
|
_set_state("idle")
|
||||||
|
content_changed.emit(content)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_remote() -> void:
|
||||||
|
if active_account_key.is_empty() or not auth_state.can_send_authenticated_request():
|
||||||
|
return
|
||||||
|
_start_request("load", HTTPClient.METHOD_GET, "")
|
||||||
|
if not dirty:
|
||||||
|
_set_state("loading")
|
||||||
|
|
||||||
|
|
||||||
|
func _save_remote() -> void:
|
||||||
|
if not dirty or not request_action.is_empty() or conflict_active:
|
||||||
|
return
|
||||||
|
if not auth_state.can_send_authenticated_request():
|
||||||
|
_set_state("offline")
|
||||||
|
return
|
||||||
|
sent_content = content
|
||||||
|
var payload := JSON.stringify({"content": sent_content, "expectedRevision": revision})
|
||||||
|
_start_request("save", HTTPClient.METHOD_PUT, payload)
|
||||||
|
_set_state("saving")
|
||||||
|
|
||||||
|
|
||||||
|
func _start_request(action: String, method: HTTPClient.Method, body: String) -> void:
|
||||||
|
request_action = action
|
||||||
|
request_access_token = auth_state.access_token
|
||||||
|
request_account_key = active_account_key
|
||||||
|
var headers := PackedStringArray([
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"Authorization: Bearer %s" % request_access_token
|
||||||
|
])
|
||||||
|
var error := request.request(api_url, headers, method, body)
|
||||||
|
if error != OK:
|
||||||
|
request_action = ""
|
||||||
|
_set_state("offline")
|
||||||
|
retry_timer.start()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||||
|
var completed_action := request_action
|
||||||
|
var completed_account := request_account_key
|
||||||
|
request_action = ""
|
||||||
|
request_account_key = ""
|
||||||
|
if completed_account != active_account_key or active_account_key.is_empty():
|
||||||
|
return
|
||||||
|
if response_code == 401:
|
||||||
|
auth_state.handle_unauthorized(request_access_token)
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code >= 500:
|
||||||
|
_set_state("offline")
|
||||||
|
if dirty:
|
||||||
|
retry_timer.start()
|
||||||
|
return
|
||||||
|
var parsed_variant: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||||
|
if typeof(parsed_variant) != TYPE_DICTIONARY:
|
||||||
|
_set_state("offline")
|
||||||
|
if dirty:
|
||||||
|
retry_timer.start()
|
||||||
|
return
|
||||||
|
var parsed := parsed_variant as Dictionary
|
||||||
|
if response_code == 409 and completed_action == "save":
|
||||||
|
_set_conflict(parsed)
|
||||||
|
return
|
||||||
|
if response_code < 200 or response_code >= 300:
|
||||||
|
_set_state("offline")
|
||||||
|
if dirty:
|
||||||
|
retry_timer.start()
|
||||||
|
return
|
||||||
|
remote_loaded = true
|
||||||
|
if completed_action == "load":
|
||||||
|
_apply_remote_load(parsed)
|
||||||
|
elif completed_action == "save":
|
||||||
|
_apply_remote_save(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_remote_load(server: Dictionary) -> void:
|
||||||
|
var server_content := str(server.get("content", ""))
|
||||||
|
var server_revision := maxi(0, int(server.get("revision", 0)))
|
||||||
|
updated_utc = str(server.get("updatedUtc", ""))
|
||||||
|
if not dirty:
|
||||||
|
content = server_content
|
||||||
|
revision = server_revision
|
||||||
|
_write_local_draft()
|
||||||
|
_set_state("saved")
|
||||||
|
content_changed.emit(content)
|
||||||
|
return
|
||||||
|
if content == server_content:
|
||||||
|
revision = server_revision
|
||||||
|
dirty = false
|
||||||
|
_write_local_draft()
|
||||||
|
_set_state("saved")
|
||||||
|
return
|
||||||
|
if revision == server_revision:
|
||||||
|
_set_state("dirty")
|
||||||
|
autosave_timer.start()
|
||||||
|
return
|
||||||
|
_set_conflict(server)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_remote_save(server: Dictionary) -> void:
|
||||||
|
revision = maxi(0, int(server.get("revision", revision + 1)))
|
||||||
|
updated_utc = str(server.get("updatedUtc", ""))
|
||||||
|
if content == sent_content:
|
||||||
|
dirty = false
|
||||||
|
_set_state("saved")
|
||||||
|
else:
|
||||||
|
dirty = true
|
||||||
|
_set_state("dirty")
|
||||||
|
autosave_timer.start()
|
||||||
|
_write_local_draft()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_conflict(server: Dictionary) -> void:
|
||||||
|
conflict_server_content = str(server.get("content", ""))
|
||||||
|
conflict_server_revision = maxi(0, int(server.get("revision", 0)))
|
||||||
|
conflict_active = true
|
||||||
|
_set_state("conflict")
|
||||||
|
conflict_detected.emit(conflict_server_content)
|
||||||
|
|
||||||
|
|
||||||
|
func _retry_sync() -> void:
|
||||||
|
if not auth_state.is_logged_in:
|
||||||
|
return
|
||||||
|
if not remote_loaded:
|
||||||
|
_load_remote()
|
||||||
|
elif dirty:
|
||||||
|
_save_remote()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_state(value: String) -> void:
|
||||||
|
if state == value:
|
||||||
|
return
|
||||||
|
state = value
|
||||||
|
state_changed.emit(state)
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_conflict() -> void:
|
||||||
|
conflict_server_content = ""
|
||||||
|
conflict_server_revision = 0
|
||||||
|
conflict_active = false
|
||||||
|
|
||||||
|
|
||||||
|
func _account_key(username: String) -> String:
|
||||||
|
return username.strip_edges().to_lower().sha256_text()
|
||||||
|
|
||||||
|
|
||||||
|
func _draft_path() -> String:
|
||||||
|
return "user://notepad_%s.json" % active_account_key
|
||||||
|
|
||||||
|
|
||||||
|
func _read_local_draft() -> void:
|
||||||
|
if active_account_key.is_empty() or not FileAccess.file_exists(_draft_path()):
|
||||||
|
return
|
||||||
|
var file := FileAccess.open(_draft_path(), FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return
|
||||||
|
var parsed_variant: Variant = JSON.parse_string(file.get_as_text())
|
||||||
|
if typeof(parsed_variant) != TYPE_DICTIONARY:
|
||||||
|
return
|
||||||
|
var parsed := parsed_variant as Dictionary
|
||||||
|
content = str(parsed.get("content", "")).left(MAX_CONTENT_LENGTH)
|
||||||
|
revision = maxi(0, int(parsed.get("revision", 0)))
|
||||||
|
dirty = bool(parsed.get("dirty", false))
|
||||||
|
_set_state("dirty" if dirty else "saved")
|
||||||
|
|
||||||
|
|
||||||
|
func _write_local_draft() -> void:
|
||||||
|
if active_account_key.is_empty():
|
||||||
|
return
|
||||||
|
var file := FileAccess.open(_draft_path(), FileAccess.WRITE)
|
||||||
|
if file == null:
|
||||||
|
return
|
||||||
|
file.store_string(JSON.stringify({
|
||||||
|
"content": content,
|
||||||
|
"revision": revision,
|
||||||
|
"dirty": dirty,
|
||||||
|
"updatedUtc": "%sZ" % Time.get_datetime_string_from_system(true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
func _cancel_request() -> void:
|
||||||
|
if request != null and request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
request.cancel_request()
|
||||||
|
request_action = ""
|
||||||
|
request_account_key = ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b8juke0d675k5
|
||||||
+20
-1
@@ -12,6 +12,7 @@ var sound_toggle: CheckButton
|
|||||||
var theme_button: Button
|
var theme_button: Button
|
||||||
var feedback_button: Button
|
var feedback_button: Button
|
||||||
var feedback_dialog: Control
|
var feedback_dialog: Control
|
||||||
|
var notes_dialog: Control
|
||||||
var session_expiry_dialog: Control
|
var session_expiry_dialog: Control
|
||||||
var theme_service
|
var theme_service
|
||||||
var current_screen_name := ""
|
var current_screen_name := ""
|
||||||
@@ -283,9 +284,18 @@ func _build_ui() -> void:
|
|||||||
screens_container.add_child(settings)
|
screens_container.add_child(settings)
|
||||||
screens["settings"] = settings
|
screens["settings"] = settings
|
||||||
|
|
||||||
|
var notes := preload("res://scripts/ui/notes_screen.gd").new()
|
||||||
|
notes.name = "NotesScreen"
|
||||||
|
notes.main = self
|
||||||
|
screens_container.add_child(notes)
|
||||||
|
screens["notes"] = notes
|
||||||
|
|
||||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||||
feedback_dialog.name = "AppFeedbackDialog"
|
feedback_dialog.name = "AppFeedbackDialog"
|
||||||
add_child(feedback_dialog)
|
add_child(feedback_dialog)
|
||||||
|
notes_dialog = preload("res://scripts/ui/notes_dialog.gd").new()
|
||||||
|
notes_dialog.name = "NotesDialog"
|
||||||
|
add_child(notes_dialog)
|
||||||
session_expiry_dialog = preload("res://scripts/ui/session_expiry_dialog.gd").new()
|
session_expiry_dialog = preload("res://scripts/ui/session_expiry_dialog.gd").new()
|
||||||
session_expiry_dialog.name = "SessionExpiryDialog"
|
session_expiry_dialog.name = "SessionExpiryDialog"
|
||||||
session_expiry_dialog.logout_requested.connect(_logout)
|
session_expiry_dialog.logout_requested.connect(_logout)
|
||||||
@@ -348,7 +358,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
|||||||
_add_nav_button(container, "flashcards", "Flashcards", "copy", false)
|
_add_nav_button(container, "flashcards", "Flashcards", "copy", false)
|
||||||
_add_nav_button(container, "study_plan", "Study Plan", "calendar-days", true)
|
_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", false)
|
||||||
_add_nav_button(container, "notes", "Notes", "notebook-pen", false)
|
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
|
||||||
|
|
||||||
var spacer := Control.new()
|
var spacer := Control.new()
|
||||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
@@ -391,6 +401,7 @@ func _on_navigation_pressed(key: String) -> void:
|
|||||||
"performance": show_screen("profile")
|
"performance": show_screen("profile")
|
||||||
"study_plan": show_screen("map")
|
"study_plan": show_screen("map")
|
||||||
"settings": show_screen("settings")
|
"settings": show_screen("settings")
|
||||||
|
"notes": show_screen("notes")
|
||||||
"logout": _logout()
|
"logout": _logout()
|
||||||
|
|
||||||
|
|
||||||
@@ -648,6 +659,14 @@ func open_feedback_dialog() -> void:
|
|||||||
feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size))
|
feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size))
|
||||||
|
|
||||||
|
|
||||||
|
func open_notes_dialog() -> void:
|
||||||
|
if not AuthState.is_logged_in or notes_dialog == null:
|
||||||
|
return
|
||||||
|
if current_screen_name != "player" or BlockSessionService.session_kind == "simulation":
|
||||||
|
return
|
||||||
|
notes_dialog.show_dialog()
|
||||||
|
|
||||||
|
|
||||||
func _on_theme_changed(is_dark_mode: bool) -> void:
|
func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||||
Style.refresh_tree(self, not is_dark_mode)
|
Style.refresh_tree(self, not is_dark_mode)
|
||||||
_refresh_theme_button()
|
_refresh_theme_button()
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
|||||||
key = "performance"
|
key = "performance"
|
||||||
elif screen_name == "settings":
|
elif screen_name == "settings":
|
||||||
key = "settings"
|
key = "settings"
|
||||||
|
elif screen_name == "notes":
|
||||||
|
key = "notes"
|
||||||
return key
|
return key
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
|
||||||
|
|
||||||
|
signal closed
|
||||||
|
|
||||||
|
var panel: PanelContainer
|
||||||
|
var notes_editor: VBoxContainer
|
||||||
|
var theme_service: Node
|
||||||
|
var block_session_service: Node
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
theme_service = get_node("/root/UIThemeService")
|
||||||
|
block_session_service = get_node("/root/BlockSessionService")
|
||||||
|
visible = false
|
||||||
|
top_level = true
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
var dim := ColorRect.new()
|
||||||
|
dim.color = Color(0, 0, 0, 0.72)
|
||||||
|
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
dim.gui_input.connect(_on_dim_input)
|
||||||
|
add_child(dim)
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(center)
|
||||||
|
panel = PanelContainer.new()
|
||||||
|
panel.custom_minimum_size = Vector2(760, 650)
|
||||||
|
center.add_child(panel)
|
||||||
|
var content := VBoxContainer.new()
|
||||||
|
content.add_theme_constant_override("separation", 12)
|
||||||
|
panel.add_child(content)
|
||||||
|
var header := HBoxContainer.new()
|
||||||
|
content.add_child(header)
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "Notes"
|
||||||
|
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
title.add_theme_font_override("font", Brand.font)
|
||||||
|
title.add_theme_font_size_override("font_size", 26)
|
||||||
|
header.add_child(title)
|
||||||
|
var close_button := Button.new()
|
||||||
|
close_button.icon = preload("res://assets/icons/lucide/x.svg")
|
||||||
|
close_button.tooltip_text = "Close notes"
|
||||||
|
close_button.custom_minimum_size = Vector2(42, 42)
|
||||||
|
close_button.add_theme_constant_override("icon_max_width", 20)
|
||||||
|
close_button.pressed.connect(hide_dialog)
|
||||||
|
header.add_child(close_button)
|
||||||
|
notes_editor = NotesEditor.new()
|
||||||
|
content.add_child(notes_editor)
|
||||||
|
theme_service.theme_changed.connect(_on_theme_changed)
|
||||||
|
resized.connect(_fit_panel)
|
||||||
|
_apply_theme()
|
||||||
|
_fit_panel()
|
||||||
|
|
||||||
|
|
||||||
|
func show_dialog() -> void:
|
||||||
|
notes_editor.activate()
|
||||||
|
visible = true
|
||||||
|
move_to_front()
|
||||||
|
block_session_service.pause_current_question_timer()
|
||||||
|
call_deferred("_focus_editor")
|
||||||
|
|
||||||
|
|
||||||
|
func hide_dialog() -> void:
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
visible = false
|
||||||
|
get_node("/root/NotesService").flush()
|
||||||
|
block_session_service.resume_current_question_timer()
|
||||||
|
closed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _focus_editor() -> void:
|
||||||
|
notes_editor.focus_editor()
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if visible and event.is_action_pressed("ui_cancel"):
|
||||||
|
hide_dialog()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_dim_input(event: InputEvent) -> void:
|
||||||
|
var mouse_event := event as InputEventMouseButton
|
||||||
|
if mouse_event != null and mouse_event.pressed and mouse_event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
hide_dialog()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_theme_changed(_dark: bool) -> void:
|
||||||
|
_apply_theme()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_theme() -> void:
|
||||||
|
panel.add_theme_stylebox_override("panel", Brand.panel_box(theme_service.is_dark_mode()))
|
||||||
|
|
||||||
|
|
||||||
|
func _fit_panel() -> void:
|
||||||
|
if panel == null:
|
||||||
|
return
|
||||||
|
var available := get_viewport_rect().size - Vector2(32, 32)
|
||||||
|
var target_size := Vector2(
|
||||||
|
minf(760.0, maxf(300.0, available.x)),
|
||||||
|
minf(650.0, maxf(420.0, available.y)))
|
||||||
|
if panel.custom_minimum_size != target_size:
|
||||||
|
panel.custom_minimum_size = target_size
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c20n7rxtocujy
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
extends VBoxContainer
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
|
||||||
|
var editor: TextEdit
|
||||||
|
var status_label: Label
|
||||||
|
var count_label: Label
|
||||||
|
var conflict_panel: PanelContainer
|
||||||
|
var applying_content := false
|
||||||
|
var notes_service: Node
|
||||||
|
var theme_service: Node
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
notes_service = get_node("/root/NotesService")
|
||||||
|
theme_service = get_node("/root/UIThemeService")
|
||||||
|
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
add_theme_constant_override("separation", 10)
|
||||||
|
|
||||||
|
var metadata_row := HBoxContainer.new()
|
||||||
|
metadata_row.add_theme_constant_override("separation", 12)
|
||||||
|
add_child(metadata_row)
|
||||||
|
status_label = Label.new()
|
||||||
|
status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
Brand.apply_font(status_label)
|
||||||
|
metadata_row.add_child(status_label)
|
||||||
|
count_label = Label.new()
|
||||||
|
Brand.apply_font(count_label)
|
||||||
|
metadata_row.add_child(count_label)
|
||||||
|
|
||||||
|
editor = TextEdit.new()
|
||||||
|
editor.placeholder_text = "Write notes..."
|
||||||
|
editor.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||||
|
editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
editor.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
editor.custom_minimum_size = Vector2(0, 260)
|
||||||
|
editor.add_theme_font_override("font", Brand.font)
|
||||||
|
editor.add_theme_font_size_override("font_size", 17)
|
||||||
|
editor.text_changed.connect(_on_text_changed)
|
||||||
|
add_child(editor)
|
||||||
|
|
||||||
|
conflict_panel = PanelContainer.new()
|
||||||
|
conflict_panel.visible = false
|
||||||
|
add_child(conflict_panel)
|
||||||
|
var conflict_content := VBoxContainer.new()
|
||||||
|
conflict_content.add_theme_constant_override("separation", 8)
|
||||||
|
conflict_panel.add_child(conflict_content)
|
||||||
|
var conflict_message := Label.new()
|
||||||
|
conflict_message.text = "This note changed on another device. Choose which version to keep."
|
||||||
|
conflict_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
Brand.apply_font(conflict_message)
|
||||||
|
conflict_content.add_child(conflict_message)
|
||||||
|
var conflict_actions := HBoxContainer.new()
|
||||||
|
conflict_actions.add_theme_constant_override("separation", 8)
|
||||||
|
conflict_content.add_child(conflict_actions)
|
||||||
|
var keep_draft := Button.new()
|
||||||
|
keep_draft.text = "Keep my draft"
|
||||||
|
keep_draft.pressed.connect(notes_service.resolve_conflict_keep_draft)
|
||||||
|
conflict_actions.add_child(keep_draft)
|
||||||
|
var use_synced := Button.new()
|
||||||
|
use_synced.text = "Use synced version"
|
||||||
|
use_synced.pressed.connect(notes_service.resolve_conflict_use_synced)
|
||||||
|
conflict_actions.add_child(use_synced)
|
||||||
|
|
||||||
|
notes_service.content_changed.connect(_on_service_content_changed)
|
||||||
|
notes_service.state_changed.connect(_on_service_state_changed)
|
||||||
|
theme_service.theme_changed.connect(_on_theme_changed)
|
||||||
|
_refresh_from_service()
|
||||||
|
_apply_theme()
|
||||||
|
|
||||||
|
|
||||||
|
func activate() -> void:
|
||||||
|
notes_service.ensure_loaded()
|
||||||
|
_refresh_from_service()
|
||||||
|
|
||||||
|
|
||||||
|
func focus_editor() -> void:
|
||||||
|
editor.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_text_changed() -> void:
|
||||||
|
if applying_content:
|
||||||
|
return
|
||||||
|
if editor.text.length() > notes_service.MAX_CONTENT_LENGTH:
|
||||||
|
var caret_line := editor.get_caret_line()
|
||||||
|
var caret_column := editor.get_caret_column()
|
||||||
|
applying_content = true
|
||||||
|
editor.text = editor.text.left(NotesService.MAX_CONTENT_LENGTH)
|
||||||
|
editor.set_caret_line(mini(caret_line, editor.get_line_count() - 1))
|
||||||
|
editor.set_caret_column(caret_column)
|
||||||
|
applying_content = false
|
||||||
|
notes_service.set_content(editor.text)
|
||||||
|
_refresh_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_service_content_changed(value: String) -> void:
|
||||||
|
if editor.text != value:
|
||||||
|
applying_content = true
|
||||||
|
editor.text = value
|
||||||
|
applying_content = false
|
||||||
|
_refresh_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_service_state_changed(_new_state: String) -> void:
|
||||||
|
_refresh_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_from_service() -> void:
|
||||||
|
_on_service_content_changed(notes_service.content)
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_metadata() -> void:
|
||||||
|
status_label.text = notes_service.status_text()
|
||||||
|
count_label.text = "%s / %s" % [
|
||||||
|
_format_count(editor.text.length()),
|
||||||
|
_format_count(notes_service.MAX_CONTENT_LENGTH)
|
||||||
|
]
|
||||||
|
conflict_panel.visible = notes_service.conflict_active
|
||||||
|
status_label.add_theme_color_override(
|
||||||
|
"font_color",
|
||||||
|
Brand.AMBER if notes_service.state in ["dirty", "offline", "conflict"] else Brand.muted_color(theme_service.is_dark_mode()))
|
||||||
|
|
||||||
|
|
||||||
|
func _format_count(value: int) -> String:
|
||||||
|
return "%d,%03d" % [int(floor(float(value) / 1000.0)), value % 1000] if value >= 1000 else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_theme_changed(_dark: bool) -> void:
|
||||||
|
_apply_theme()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_theme() -> void:
|
||||||
|
var dark: bool = theme_service.is_dark_mode()
|
||||||
|
editor.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||||
|
editor.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
|
||||||
|
editor.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
|
||||||
|
editor.add_theme_color_override("selection_color", Color(Brand.ROYAL_BLUE, 0.32))
|
||||||
|
editor.add_theme_stylebox_override("normal", Brand.field_box(dark))
|
||||||
|
editor.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
|
||||||
|
conflict_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
|
||||||
|
_refresh_metadata()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://du8xhjfdkdakr
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
extends VBoxContainer
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
|
||||||
|
|
||||||
|
var main
|
||||||
|
var notes_editor: VBoxContainer
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
add_theme_constant_override("separation", 16)
|
||||||
|
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 = "Notes"
|
||||||
|
title.add_theme_font_override("font", Brand.font)
|
||||||
|
title.add_theme_font_size_override("font_size", 32)
|
||||||
|
add_child(title)
|
||||||
|
notes_editor = NotesEditor.new()
|
||||||
|
add_child(notes_editor)
|
||||||
|
|
||||||
|
|
||||||
|
func on_show(_payload: Variant = null) -> void:
|
||||||
|
notes_editor.activate()
|
||||||
|
|
||||||
|
|
||||||
|
func on_hide() -> void:
|
||||||
|
get_node("/root/NotesService").flush()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://e7os1ufiuo37
|
||||||
@@ -83,6 +83,7 @@ var labs_button: Button
|
|||||||
var clear_highlight_button: Button
|
var clear_highlight_button: Button
|
||||||
var flag_button: Button
|
var flag_button: Button
|
||||||
var report_button: Button
|
var report_button: Button
|
||||||
|
var notes_button: Button
|
||||||
var app_feedback_button: Button
|
var app_feedback_button: Button
|
||||||
var theme_button: Button
|
var theme_button: Button
|
||||||
var question_toolbar: HFlowContainer
|
var question_toolbar: HFlowContainer
|
||||||
@@ -273,6 +274,18 @@ func _ready() -> void:
|
|||||||
report_button.pressed.connect(_toggle_report_current_question)
|
report_button.pressed.connect(_toggle_report_current_question)
|
||||||
question_toolbar.add_child(report_button)
|
question_toolbar.add_child(report_button)
|
||||||
|
|
||||||
|
notes_button = Button.new()
|
||||||
|
notes_button.tooltip_text = "Open notes"
|
||||||
|
notes_button.icon = preload("res://assets/icons/lucide/notebook-pen.svg")
|
||||||
|
notes_button.custom_minimum_size = Vector2(44, 44)
|
||||||
|
notes_button.add_theme_constant_override("icon_max_width", 22)
|
||||||
|
notes_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
notes_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
notes_button.focus_mode = Control.FOCUS_NONE
|
||||||
|
UISoundService.set_button_sound(notes_button, "open")
|
||||||
|
notes_button.pressed.connect(func(): main.open_notes_dialog())
|
||||||
|
question_toolbar.add_child(notes_button)
|
||||||
|
|
||||||
app_feedback_button = Button.new()
|
app_feedback_button = Button.new()
|
||||||
app_feedback_button.text = "Feedback"
|
app_feedback_button.text = "Feedback"
|
||||||
app_feedback_button.tooltip_text = "Send general feedback or report a bug"
|
app_feedback_button.tooltip_text = "Send general feedback or report a bug"
|
||||||
@@ -827,7 +840,7 @@ func _apply_question_theme() -> void:
|
|||||||
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||||
if navigator_heading != null:
|
if navigator_heading != null:
|
||||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||||
for control in [text_smaller_button, text_larger_button, flag_button, app_feedback_button]:
|
for control in [text_smaller_button, text_larger_button, flag_button, notes_button, app_feedback_button]:
|
||||||
if control == null:
|
if control == null:
|
||||||
continue
|
continue
|
||||||
var active: bool = control == flag_button and flagged
|
var active: bool = control == flag_button and flagged
|
||||||
@@ -1864,6 +1877,8 @@ func _refresh_toolbar_state() -> void:
|
|||||||
if report_button != null:
|
if report_button != null:
|
||||||
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
||||||
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
|
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"
|
||||||
_apply_question_theme()
|
_apply_question_theme()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
|
||||||
|
const NotesDialog := preload("res://scripts/ui/notes_dialog.gd")
|
||||||
|
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var editor := NotesEditor.new()
|
||||||
|
root.add_child(editor)
|
||||||
|
await process_frame
|
||||||
|
_assert(editor.editor != null, "notes editor should create a text editor")
|
||||||
|
_assert(editor.editor.wrap_mode == TextEdit.LINE_WRAPPING_BOUNDARY, "notes should wrap plain text")
|
||||||
|
_assert(NavigationRoutes.key_for_screen("notes") == "notes", "notes should have a navigation route")
|
||||||
|
|
||||||
|
var dialog := NotesDialog.new()
|
||||||
|
root.add_child(dialog)
|
||||||
|
await process_frame
|
||||||
|
_assert(not dialog.visible, "notes dialog should start hidden")
|
||||||
|
_assert(dialog.notes_editor != null, "notes dialog should contain the shared editor")
|
||||||
|
|
||||||
|
editor.queue_free()
|
||||||
|
dialog.queue_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://lo6a7h02ld0l
|
||||||
@@ -348,6 +348,7 @@ func _test_player_runtime_construction() -> void:
|
|||||||
_assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork")
|
_assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork")
|
||||||
_assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork")
|
_assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork")
|
||||||
_assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork")
|
_assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork")
|
||||||
|
_assert(player.notes_button != null and player.notes_button.icon != null, "practice questions should expose the notes tool")
|
||||||
for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]:
|
for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]:
|
||||||
_assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered")
|
_assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered")
|
||||||
_assert(icon_button.vertical_icon_alignment == VERTICAL_ALIGNMENT_CENTER, "question toolbar artwork should be vertically centered")
|
_assert(icon_button.vertical_icon_alignment == VERTICAL_ALIGNMENT_CENTER, "question toolbar artwork should be vertically centered")
|
||||||
@@ -362,6 +363,14 @@ func _test_player_runtime_construction() -> void:
|
|||||||
_assert(player.theme_button.icon in [Brand.SUN_ICON, Brand.MOON_ICON], "question theme toggle should use the shared sun or moon icon")
|
_assert(player.theme_button.icon in [Brand.SUN_ICON, Brand.MOON_ICON], "question theme toggle should use the shared sun or moon icon")
|
||||||
_assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot")
|
_assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot")
|
||||||
_assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice")
|
_assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice")
|
||||||
|
var prior_session_kind: String = root.get_node("BlockSessionService").session_kind
|
||||||
|
root.get_node("BlockSessionService").session_kind = "simulation"
|
||||||
|
player._refresh_toolbar_state()
|
||||||
|
_assert(not player.notes_button.visible, "timed simulations should hide the notes tool")
|
||||||
|
root.get_node("BlockSessionService").session_kind = "core"
|
||||||
|
player._refresh_toolbar_state()
|
||||||
|
_assert(player.notes_button.visible, "practice question runs should show the notes tool")
|
||||||
|
root.get_node("BlockSessionService").session_kind = prior_session_kind
|
||||||
player.choice_followup_panels[2].visible = true
|
player.choice_followup_panels[2].visible = true
|
||||||
player.locked_wrong_choice_ids = ["C"]
|
player.locked_wrong_choice_ids = ["C"]
|
||||||
player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}, {"id": "C", "text": "Seek a court order"}]
|
player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}, {"id": "C", "text": "Seek a court order"}]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func _initialize() -> void:
|
|||||||
_assert(NavigationRoutes.key_for_screen("profile") == "performance", "profile should activate Performance")
|
_assert(NavigationRoutes.key_for_screen("profile") == "performance", "profile should activate Performance")
|
||||||
_assert(NavigationRoutes.key_for_screen("map") == "study_plan", "study map should activate Study Plan")
|
_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("settings") == "settings", "settings should activate Settings")
|
||||||
|
_assert(NavigationRoutes.key_for_screen("notes") == "notes", "notes should activate Notes")
|
||||||
quit(1 if failures > 0 else 0)
|
quit(1 if failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user