Repository navigation
choose configuration profile UI (WP-1022) - #638
Conversation
First step of letting a user choose which Smartling profile/project a translation request goes to, instead of relying on whichever profile is flagged "active": the request model itself now requires the caller to say which profile it's for, validated the same way the existing required job/source fields are. Updated every constructor/fromArray() call site (TestRunController, and the test suites exercising UserTranslationRequest/ ContentRelationsDiscoveryService/ContentRelationsHandler) to pass one. Note: ContentRelationsDiscoveryService::createSubmissions() doesn't read getProfileId() yet - it still resolves the active profile unconditionally. That wiring, plus the frontend profile selector that will actually populate this field in real AJAX requests, are separate steps in the same ticket.
createSubmissions() always resolved the profile via getSingleSettingsProfile($curBlogId) - whichever one happens to be flagged active - ignoring UserTranslationRequest::getProfileId() entirely. Now resolves the requested profile explicitly (validating it exists, belongs to the current blog, and is active), falling back to today's active-profile behavior only if the requested id is stale (profile deactivated/deleted client-side after the page loaded) rather than hard-failing the whole request. This is the single resolution point both the bulk and non-bulk branches already share, so the profile-stamping added for WP-1021 (createBatch(), setConfigurationProfileId() at enqueue time) now automatically uses the user's explicit choice with no further changes needed at those call sites.
list-jobs/create-job previously always resolved the blog's active profile via getSingleSettingsProfile(), ignoring any profileId the client may have selected. Mirror the fallback behavior already added to ContentRelationsDiscoveryService: use the requested profile when it exists, belongs to the current blog, and is active; otherwise fall back to the active profile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Surface all active profiles for the current blog (not just the first one) to the React job wizard shared by the post/taxonomy edit screen and bulk submit page, each with its own target-locale set. Add a profile dropdown (shown only when more than one profile applies), remember the chosen profile per-blog in localStorage, and send its id with list-jobs/create-job/smartling-create-submissions so the request is routed to the profile the user picked instead of whichever one happens to be flagged active. Switching profiles reconciles the selected target locales and refreshes the existing-jobs list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The per-post "Smartling Widget" (side metabox) only showed a link to a target placeholder post once its row had been rendered server-side on page load, so after queuing a new upload from the job wizard the user had to manually reload the page to get a link to the freshly created placeholder. Add an AJAX endpoint that re-renders that metabox's markup on demand, and have the job wizard poll it with a short backoff after a successful (non bulk-submit) upload, swapping in the refreshed widget until every requested target locale has an edit link or the poll window runs out. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…1022) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… client (WP-1022) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
sl-mmuradov
left a comment
There was a problem hiding this comment.
Thanks, centralizing the lookup in resolveRequestedProfile() with a strict check (exists, same blog, active) is the right approach, and keeping error details server-side is good. A few things block merging, though:
Blocking
- Related content still uses the default profile.
SubmissionManager::getSubmissionEntity()always stampsgetSingleSettingsProfile(), even on existing submissions. Related content created during upload or download (referenced posts and terms, images,downloadTranslation) therefore goes to profile A while the parent and job are on B, and existing B submissions are re-stamped to A. This defeats the main goal of the ticket for any content with relations. - Target blogs aren't validated against the chosen profile in
createSubmissions,create-jobor instant translation. The result is an empty Smartling locale and a failure later on. - The widget refresh breaks the side widget. The
outerHTMLswap drops the Download and checkbox handlers. The AJAX action is registered by every post-type controller, so only the first post type renders correct submissions. Taxonomy screens poll with a term id aspostId. ContentRelationsHandlerreports everySmartlingDbExceptionas "Invalid translation profile", which hides real DB errors.
Should fix
- Re-stamping in-progress submissions under a different profile leaves them pointing at the wrong project. Block or warn.
- The wizard leaves the old profile's jobs selectable after a switch, doesn't clear its error, and re-fires all relation requests.
- The side widget merges locales from all profiles, but its legacy upload path uses only the first profile.
Scope question: the ticket asks for a per-blog default profile. Right now it's "first active by id" plus localStorage. Is that a follow-up ticket?
Tests: please add tests for ajaxRefreshWidgetHandler, the job proxy's profile 400 path, the handler's exception mapping, related-content stamping when the requested profile isn't the default, and Playwright coverage for the dropdown, the reset on switch and the widget refresh.
Inline comments have details and suggested fixes.
| // them with the profile this request's batch is being created under (below), so | ||
| // UploadJob never has to guess at a profile for them later. | ||
| // New submissions built from this template bypass getSubmissionEntity(), stamp them with the requested profile. | ||
| $submissionTemplateArray[SubmissionEntity::FIELD_CONFIGURATION_PROFILE_ID] = $profile->getId(); |
There was a problem hiding this comment.
[High] Parent submissions are stamped with the requested profile here, but related content created later still gets the default profile. SubmissionManager::getSubmissionEntity() → stampConfigurationProfile() → getSingleSettingsProfile() (SubmissionManager.php ~489) runs for new and existing submissions. It's reached from TranslationHelper::tryPrepareRelatedContent() via ReferencedContentProcessor (referenced posts/terms in meta), SmartlingCoreExportApi::sendAttachmentForTranslation() (Gutenberg/Elementor images) and SmartlingCoreDownloadTrait::downloadTranslation().
With profile B chosen, children get profile A's credentials but B's job (JobEntityWithBatchUid::fromJob($submission->getJobInfo())), and existing B submissions are silently re-stamped to A. This was harmless while "active" meant "requested". Now that several profiles can be active, it isn't. Suggest: pass the parent's profile id into related-content creation, and in getSubmissionEntity() stamp only when getConfigurationProfileId() === null.
There was a problem hiding this comment.
Fixed. SubmissionManager::getSubmissionEntity() now only stamps the source blog's default profile when none is set yet; an explicit profile id always wins. Threaded that through TranslationHelper (prepareSubmission/prepareSubmissionEntity/tryPrepareRelatedContent/getExistingSubmissionOrCreateNew), and ReferencedContentProcessor::processFieldPreTranslation() now passes the parent submission's own profile id.
Checked sendAttachmentForTranslation() and SmartlingCoreDownloadTrait::downloadTranslation(): neither has any caller anywhere in the codebase currently (grepped inc/) - the only live path creating related-content submissions is ReferencedContentProcessor during upload serialization. Added the optional profile id param to sendAttachmentForTranslation() too for signature consistency, but didn't invent a call site for downloadTranslation() since nothing reaches it today.
(5847c8d, tests in SubmissionManagerTest/ReferencedContentProcessorTest)
| { | ||
| $curBlogId = $this->wordpressProxy->get_current_blog_id(); | ||
| $profile = $this->settingsManager->getSingleSettingsProfile($curBlogId); | ||
| $profile = $this->settingsManager->resolveRequestedProfile($request->getProfileId(), $curBlogId); |
There was a problem hiding this comment.
[High] targetBlogIds aren't checked against the resolved profile's enabled target locales. A blog outside profile B (stale tab, crafted request, or a locale picked in the side widget, which now merges all profiles) is accepted. getSmartlingLocaleIdBySettingsProfile() then returns '', so the upload fails later, away from the request. Can we reject it here with a 400? The same applies to create-job (ContentEditJobController.php:161) and instant translation (InstantTranslationController.php:58).
There was a problem hiding this comment.
Added SettingsManager::assertTargetBlogIdsBelongToProfile(), called from createSubmissions() (covers both the bulk and non-bulk paths), ContentEditJobController's create-job case, InstantTranslationController::handleRequestTranslation(), and the legacy widget's ajaxUploadHandler(). All four now reject with a 400 before doing any work instead of silently producing an empty Smartling locale.
(5847c8d)
| if (!widget) { | ||
| return; | ||
| } | ||
| widget.outerHTML = response.html; |
There was a problem hiding this comment.
[High] widget.outerHTML = response.html destroys the handlers smartling-connector-admin.js bound at page load. #smartling-download is bound directly (:196), and the checkbox handler is delegated from the widget node itself (:54). After the first refresh, Download does nothing and checkbox value syncing stops until the page is reloaded. Options: replace only the inner content and re-init the handlers, or move those handlers to $(document).on(...) delegation.
There was a problem hiding this comment.
Fixed the live bug: the Download button's click handler in smartling-connector-admin.js is now delegated from document instead of bound directly to #smartling-download, so it survives the outerHTML swap.
Checked the checkbox-sync handler you mentioned (setCheckboxValue, bound in init()): init() only runs if ($(this.selectors.form).length > 0), and #smartling-form isn't rendered anywhere in the codebase (grepped inc/ and js/) - so that delegation was already dead code before this change, unrelated to the refresh. Left it alone rather than fixing an unrelated pre-existing gap in this PR.
(ab8892e)
There was a problem hiding this comment.
Follow-up: the delegated-binding fix here was itself only a patch on a feature that kept causing problems (this, the multi-post-type collision, the taxonomy postId mismatch, and a nonce/referer duplication bug you found in the next round). Removed the whole widget auto-refresh instead of continuing to patch it - the Download button binding is back to the original direct $(selector).on(...), since there's no more outerHTML replacement for it to survive.
(50715fc)
| add_action('save_post', [$this, 'save']); // old logic 2 be refactored | ||
| add_action('wp_ajax_' . 'smartling_force_download_handler', [$this, 'ajaxDownloadHandler']); | ||
| add_action('wp_ajax_' . 'smartling_upload_handler', [$this, 'ajaxUploadHandler']); | ||
| add_action('wp_ajax_' . 'smartling_refresh_post_widget', [$this, 'ajaxRefreshWidgetHandler']); |
There was a problem hiding this comment.
[High] CustomPostType::registerWidgetHandler() (CustomPostType.php:63) creates one controller per post type, and each registers this same wp_ajax_smartling_refresh_post_widget. The first registered controller handles every request and wp_send_json ends the request. Its preView() then filters submissions by its own servedContentType, so on a page or CPT the refreshed widget has no statuses. allReady never becomes true, so it polls all 5 times. Suggest registering the action once and using $post->post_type, or checking $post->post_type === $this->servedContentType and returning early so the matching controller can answer.
There was a problem hiding this comment.
Fixed as you suggested: ajaxRefreshWidgetHandler() now returns early (without responding) when $post->post_type !== $this->servedContentType, letting the matching post type's instance answer instead.
(ab8892e)
There was a problem hiding this comment.
Follow-up: removed ajaxRefreshWidgetHandler() and the smartling_refresh_post_widget action entirely rather than continuing to patch the auto-refresh feature (see the reply on the nonce-duplication comment from the latest round for why). This collision no longer applies since the endpoint doesn't exist anymore.
(50715fc)
| throw new Error(submissionResponse.message?.global || 'Failed to add content to upload queue.'); | ||
| } | ||
| setSuccess('Content successfully added to upload queue.'); | ||
| if (!isBulkSubmitPage) { |
There was a problem hiding this comment.
[High] !isBulkSubmitPage is also true on taxonomy term edit screens, which render the wizard (ContentEditJobController::box for WP_Term) and a #smartling-post-widget (taxonomy-based-content-type.php:61). The term id goes out as postId, so get_post(<termId>) either swaps in an unrelated post's widget or returns a 404 on every poll. Please limit polling to the post base type, for example by passing data-base-type to the wizard.
There was a problem hiding this comment.
Added data-base-type to the #smartling-app container (post/taxonomy, from the same $baseType already computed in ContentEditJob.php) and gated the refresh call on baseType === 'post'. Bulk submit is unaffected (already gated on isBulkSubmitPage, and is always post-type content).
(ab8892e)
There was a problem hiding this comment.
Follow-up: moot now - removed the widget auto-refresh feature entirely (the data-base-type gating along with it), so there's no more polling to misfire on taxonomy screens.
(50715fc)
| try { | ||
| $this->service->createSubmissions(UserTranslationRequest::fromArray($data)); | ||
| $this->returnResponse(['status' => BaseAjaxServiceAbstract::RESPONSE_SUCCESS]); | ||
| } catch (SmartlingDbException $e) { |
There was a problem hiding this comment.
[Medium] SmartlingDbException is also thrown by DB, Queue, TaxonomyEntityStd, GravityFormsFormHandler, SmartlingCore and others, all reachable from createSubmissions(). Real DB or content errors will now show as "Invalid translation profile" and the original message is lost. It's also misleading when no profileId is sent and the blog has no active profile. Suggest a dedicated exception for profile resolution, or resolving the profile before this try block like the other two controllers do.
There was a problem hiding this comment.
SettingsManager::resolveRequestedProfile() now throws SmartlingHumanReadableException instead of SmartlingDbException, and createSubmissionsHandler() catches that specifically (same pattern actionHandler() already used) before falling through to the generic Exception branch - so an unrelated SmartlingDbException now keeps its own message instead of being relabeled as an invalid profile.
(5847c8d, tests in ContentRelationsHandlerTest)
| // that method, so it would otherwise keep whatever profile (or none) it had. | ||
| // Resubmitting an existing submission is an explicit new translation request, so restamp it with the | ||
| // profile it was requested with. | ||
| $submission->setConfigurationProfileId($profile->getId()); |
There was a problem hiding this comment.
[Medium] (also applies to :110 and InstantTranslationController.php:323) Re-stamping an existing submission unconditionally means content that is IN_PROGRESS in project A and resubmitted under B loses its link to A's file and job. Status checks and download then go to B. Should we block or warn when the stored profile differs and the submission isn't completed or failed yet?
There was a problem hiding this comment.
Added a warning (not a block, since this is a resubmission the user explicitly asked for) when an existing submission is still IN_PROGRESS under a different profile - logs the submission id and old/new profile ids before the restamp. Same in InstantTranslationController::getOrCreateSubmission(). Happy to make this hard-block instead if that's the preference.
(5847c8d)
| action: 'smartling_job_api_proxy', | ||
| _wpnonce: nonce, | ||
| innerAction: 'list-jobs', | ||
| params: { profileId: selectedProfileId } |
There was a problem hiding this comment.
[Medium] On a profile switch the old job list stays selectable until the new one arrives (and stays forever if the request fails). selectedJob is cleared, but the user can still pick one of A's jobs and submit with profileId=B, which fails at createBatch. Suggest setJobs([]); setLoading(true); setError(''); at the start of the effect. Also, setError('Failed to load jobs') (:111) is never cleared after a later successful load.
There was a problem hiding this comment.
Applied as suggested: setJobs([]); setLoading(true); setError(''); at the start of the effect.
(ab8892e)
| const [activeTab, setActiveTab] = useState('new'); | ||
| const [selectedProfileId, setSelectedProfileId] = useState(() => { | ||
| const stored = getStoredProfileId(blogId); | ||
| return (stored !== null && profiles.some(p => p.id === stored)) ? stored : profiles[0]?.id; |
There was a problem hiding this comment.
[Medium] The ticket says "One profile can be marked default for a blog; the user can override it." Here the default is profiles[0] (DB id order) overridden by per-browser localStorage. The server fallback is also "first active by id", as are cron (JobAbstract::getActiveProfile()), test run and the legacy widget upload. Is an admin-configurable default planned as a follow-up ticket?
There was a problem hiding this comment.
This was a deliberate scope decision made with the ticket owner before implementation, not an oversight: an admin-configurable per-blog default was discussed and intentionally dropped in favor of 'first active by id' plus a per-browser localStorage override, to avoid data-model and profile-management-UI changes for what's expected to be an edge case (most blogs only ever have one active profile). An admin-configurable default would be a reasonable follow-up ticket if it turns out to matter in practice.
| */ | ||
| $locales = $data['profile']->getTargetLocales(); | ||
| $locales = []; | ||
| foreach ($data['profiles'] ?? [$data['profile']] as $profile) { |
There was a problem hiding this comment.
[Medium] The side widget now lists locales from all profiles (first one wins per blog), but its legacy upload path ajaxUploadHandler() still uses ArrayHelper::first($this->getProfiles()) (PostBasedWidgetControllerStd.php:220). Choosing a blog that only belongs to profile 2 uploads through profile 1 with an empty Smartling locale. The "settings" link in the empty state (:150) also points only at the first profile.
There was a problem hiding this comment.
Fixed both. ajaxUploadHandler() now searches the blog's active profiles for the one whose enabled target locales actually cover every selected target blog (rejecting with a clear error if none does), instead of always using the first active one. The empty-state link now points at the profile list page instead of one specific profile's edit screen.
(5847c8d)
…-1022) Several bugs surfaced once more than one profile can be active for a blog: - Related content created while processing a submission (referenced posts/terms, images) always got the source blog's default active profile via SubmissionManager::getSubmissionEntity(), even when the parent submission had been explicitly stamped with a different, user-requested one - silently sending it to the wrong Smartling project. getSubmissionEntity() now only falls back to the default when nothing is stamped yet, and an explicit profile id (the parent's) always wins. Threaded through TranslationHelper/ReferencedContentProcessor/SmartlingCoreExportApi and the legacy per-post widget's upload handler, which also now picks the profile that actually covers the selected target blogs instead of always using the first active one. - Target blog ids were never checked against the resolved profile's enabled locales in createSubmissions(), create-job, instant translation, or the legacy widget - a blog outside the profile produced an empty Smartling locale and failed later, away from the request. SettingsManager::assertTargetBlogIdsBelongToProfile() rejects this up front with a 400. - SettingsManager::resolveRequestedProfile() now throws SmartlingHumanReadableException instead of the broader SmartlingDbException, so ContentRelationsHandler can map profile resolution failures to a clean 400 without mislabeling unrelated DB errors as invalid profile errors. - Resubmitting a submission that is still IN_PROGRESS under a different profile now logs a warning, since it loses its link to the previous profile's file/job. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Swapping in the refreshed widget via outerHTML replaced the DOM node smartling-connector-admin.js had bound its Download click handler to, silently breaking the button after the first refresh. Delegate that binding from document instead, so it survives the node being replaced. - The per-post-type widget controller that registers smartling_refresh_post_widget is instantiated once per post type, so every instance was handling every request and rendering its own content type's (usually empty) submissions for any other type's post. Each instance now returns early when the requested post isn't its own served content type, letting the matching one respond. - The job wizard also renders on taxonomy term edit screens, where !isBulkSubmitPage is true but there is no post behind the term id - the refresh call was sending a term id as postId. Gate it on a new data-base-type attribute so it only fires for actual posts. - On a profile switch, the previous profile's jobs stayed selectable (and a prior load error stayed visible) until the new list arrived, or forever on failure. Clear both up front instead of waiting for the response. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed all four blocking issues and the three "should fix" items (replies inline on each comment; commits 5847c8d + ab8892e). Summary:
Unit suite: 751/751 passing. Tests added: Not covered by new tests: the |
| $locales = []; | ||
| foreach ($data['profiles'] ?? [$data['profile']] as $profile) { | ||
| foreach ($profile->getTargetLocales() as $locale) { | ||
| $locales[$locale->getBlogId()] ??= $locale; |
There was a problem hiding this comment.
[High] ??= keeps the first profile's entry for each blog, even when that entry is disabled. Every profile stores a TargetLocale for every network blog, with an enabled flag (ConfigurationProfileFormController.php:239-256). So profile A's disabled entry for blog 3 wins over profile B's enabled one, and the isEnabled() filter below then drops it. In practice the side widget still shows only the first profile's enabled locales, so the multi-profile merge does nothing. Suggest merging only enabled locales:
foreach ($profile->getTargetLocales() as $locale) {
if ($locale->isEnabled()) {
$locales[$locale->getBlogId()] ??= $locale;
}
}There was a problem hiding this comment.
Fixed - merge now only considers enabled locales:
if ($locale->isEnabled()) {
$locales[$locale->getBlogId()] ??= $locale;
}(50715fc)
| } | ||
|
|
||
| ob_start(); | ||
| $this->preView($post); |
There was a problem hiding this comment.
[Medium] preView() calls wp_nonce_field(self::WIDGET_NAME, self::CONNECTOR_NONCE) (:535) before the view prints #smartling-post-widget. That makes response.html the nonce input, plus a _wp_http_referer input, plus the widget. Because app.js:50 replaces only the widget's outerHTML, each poll (up to 5) adds another #smartling_connector_nonce and another _wp_http_referer input to the post edit form. The _wp_http_referer value is /wp-admin/admin-ajax.php, and because it comes later in the DOM it overrides the form's own referer on submit. Suggest rendering only the view here (pull the $this->view([...]) part out of preView()), or parsing the response in JS and swapping in only its #smartling-post-widget node.
There was a problem hiding this comment.
Good catch, and it's a symptom of a bigger problem - the whole auto-refresh approach was fragile in ways that kept surfacing new issues (this one, the multi-post-type collision, the taxonomy postId mismatch). Rather than patch this one too, removed the refresh feature entirely: ajaxRefreshWidgetHandler(), the smartling_refresh_post_widget AJAX action, the frontend poller, and the plumbing that only existed to support it. The widget now only re-renders on normal page load, same as before this PR touched it.
(50715fc)
| if ($continue) { | ||
| $targetBlogIds = array_map('intval', $data['blogs']); | ||
| $matchedProfile = null; | ||
| foreach ($this->getProfiles() as $candidateProfile) { |
There was a problem hiding this comment.
[Medium] Choosing "the first active profile that covers all selected blogs" ignores which project $data['job']['id'] belongs to. If both A and B cover the selected blogs and the job is B's, this picks A. The submissions then get A's project id with B's job uid, and createBatch fails later. Also, nothing in js/ calls smartling_upload_handler any more, so this guessing code only serves external callers. Suggest accepting an explicit profileId and reusing resolveRequestedProfile() + assertTargetBlogIdsBelongToProfile() like the other three entry points do, instead of inferring it.
There was a problem hiding this comment.
Fixed as suggested. ajaxUploadHandler() now resolves the profile via resolveRequestedProfile() (accepting an explicit profileId from $data, falling back to the blog's single active profile when none is sent) and validates target blogs with assertTargetBlogIdsBelongToProfile(), same as the other three entry points - no more inferring from which blogs happen to be covered.
(50715fc)
| }; | ||
| }, [adminUrl, nonce, selectedProfileId]); | ||
|
|
||
| const handleProfileChange = (val) => { |
There was a problem hiding this comment.
[Medium] This is still open from the previous round ("re-fires all relation requests"). loadRelations depends on locales (:163), so a profile switch re-runs the depth-1 effect, and at depth 2 it also re-fetches every l1Relations entry. l1Relations/l2Relations are appended, never reset, so relations found for the old profile's target blogs stay listed and selectable, and totalRequests keeps growing, which skews the progress bar. Suggest resetting l1Relations, l2Relations, selectedRelations, pendingRequests and totalRequests here.
There was a problem hiding this comment.
Fixed. handleProfileChange now also resets l1Relations, l2Relations, selectedRelations, pendingRequests and totalRequests, so the depth effect's re-fetch (triggered by loadRelations's identity changing with the new locales) starts clean instead of appending to the old profile's entries.
(50715fc)
…elation staleness (WP-1022) The post-upload widget auto-refresh (smartling_refresh_post_widget, refreshDownloadWidgetUntilReady) caused more problems than it solved - duplicate nonce/referer inputs accumulating in the post edit form on each poll (overriding the real referer on submit), a handler-binding footgun from swapping the widget via outerHTML, and the multi-post-type AJAX registration collision. Removed it entirely rather than patching further: the ajaxRefreshWidgetHandler endpoint, its frontend poller, the data-base-type plumbing that only existed to gate it, and the Download button's delegated-from-document binding that only existed to survive the outerHTML swap. Also, from the latest review round: - post-based-content-type.php's multi-profile locale merge used `??=` before checking isEnabled(), so a disabled entry from the first profile could shadow an enabled one from a later profile. Now only enabled locales are merged. - The legacy widget's ajaxUploadHandler() inferred the profile by searching for one that happens to cover the selected blogs, which ignores which project the job actually belongs to. It now resolves via resolveRequestedProfile() (accepting an explicit profileId) and validates target blogs with assertTargetBlogIdsBelongToProfile(), consistent with the other three entry points. - Switching profiles in the job wizard left relations fetched for the old profile's target locales in place, so the new fetch appended instead of replacing and the progress bar's totalRequests kept growing. handleProfileChange now resets l1Relations, l2Relations, selectedRelations, pendingRequests and totalRequests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the latest review round (commit 50715fc, replies inline):
Unit suite: 751/751 passing. |
No description provided.