Skip to content

Quick Add Capture(s) to Dataset#259

Open
LLKruczek wants to merge 3 commits intomasterfrom
lk/quick-add-capture
Open

Quick Add Capture(s) to Dataset#259
LLKruczek wants to merge 3 commits intomasterfrom
lk/quick-add-capture

Conversation

@LLKruczek
Copy link
Collaborator

No description provided.

@LLKruczek LLKruczek requested a review from lucaspar February 17, 2026 21:00
@semanticdiff-com
Copy link

semanticdiff-com bot commented Feb 17, 2026

@lucaspar lucaspar added gateway Gateway component styling Special focus on styling of front-end components javascript Pull requests that update non-trivial javascript code labels Feb 17, 2026
@lucaspar lucaspar requested a review from Copilot February 23, 2026 14:21
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements a "Quick Add Capture(s) to Dataset" feature that allows users to add one or multiple captures to a dataset through two workflows: (1) a selection mode in the captures table where users can select multiple captures via checkboxes, and (2) planned support for single-capture quick-add from dropdown menus (though the UI for this is not yet implemented in the templates).

Changes:

  • Added backend API endpoints for quick-adding captures to datasets and retrieving eligible datasets
  • Implemented frontend selection mode with checkbox-based multi-select in the captures table
  • Created a modal interface for selecting the target dataset and confirming the operation
  • Added support for automatic multi-channel capture grouping during addition

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
gateway/sds_gateway/users/views.py Added QuickAddCapturesToDatasetView for adding captures and UserDatasetsForQuickAddView for listing eligible datasets with proper permission checks
gateway/sds_gateway/users/urls.py Registered two new URL patterns for the quick-add API endpoints
gateway/sds_gateway/templates/users/partials/quick_add_to_dataset_modal.html New modal template for dataset selection with accessible form controls
gateway/sds_gateway/templates/users/partials/captures_page_table.html Added selection column with checkboxes (hidden by default, shown in selection mode)
gateway/sds_gateway/templates/users/file_list.html Integrated selection mode buttons and quick-add modal into the file list page
gateway/sds_gateway/static/js/file-list.js Implemented selection mode toggle, checkbox state management, and row click handling
gateway/sds_gateway/static/js/components.js Updated row click handler to ignore clicks on selection checkboxes
gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js New manager class handling modal interactions, dataset loading, and API calls for single/multi capture addition
gateway/sds_gateway/static/css/file-list.css Added CSS rules to show/hide selection column based on selection mode state

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +235 to +244
this.showMessage(msg, hasErrors ? "warning" : "success");
if (window.showAlert)
window.showAlert(msg, hasErrors ? "warning" : "success");
if (hasSuccess || !hasErrors) {
setTimeout(() => {
window.bootstrap?.Modal?.getInstance(this.modalEl)?.hide();
}, 1500);
} else if (this.confirmBtn) {
this.confirmBtn.disabled = false;
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After successfully adding captures to a dataset, the modal closes but selection mode remains active with checkboxes still checked. The modal should exit selection mode after a successful add operation by calling exitSelectionMode (or exposing it globally so the manager can access it) to provide a clean user experience.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +30
<th scope="col"
class="capture-select-column"
id="select-header"
aria-label="Select">Select</th>
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The captures table includes a "Select" column header with the ID "select-header", but this column header lacks an accessible label explaining its purpose. While the aria-label attribute is present, for better accessibility the visible text "Select" should be retained within the th element, or a screen reader-only text should be provided using Bootstrap's visually-hidden class.

Copilot uses AI. Check for mistakes.
Comment on lines +156 to +166
if (addBtn) {
addBtn.addEventListener("click", () => {
const modal = document.getElementById("quickAddToDatasetModal");
if (modal) {
const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []);
modal.dataset.captureUuids = JSON.stringify(ids);
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
}
});
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When clicking the "Add" button with no captures selected, the modal still opens. The add button should be disabled or the code should validate that at least one capture is selected before opening the modal, with an appropriate message displayed if none are selected.

Copilot uses AI. Check for mistakes.
Comment on lines +3189 to +3199
shared_perms = UserSharePermission.objects.filter(
shared_with=user,
item_type=ItemType.DATASET,
is_deleted=False,
is_enabled=True,
permission_level__in=[
PermissionLevel.CO_OWNER,
PermissionLevel.CONTRIBUTOR,
],
)
shared_uuids = [p.item_uuid for p in shared_perms]
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iterating over shared_perms to extract item_uuid values results in fetching all UserSharePermission objects into Python memory when only UUIDs are needed. This can be optimized by using values_list to extract UUIDs directly in the database query: shared_uuids = list(UserSharePermission.objects.filter(...).values_list('item_uuid', flat=True))

Suggested change
shared_perms = UserSharePermission.objects.filter(
shared_with=user,
item_type=ItemType.DATASET,
is_deleted=False,
is_enabled=True,
permission_level__in=[
PermissionLevel.CO_OWNER,
PermissionLevel.CONTRIBUTOR,
],
)
shared_uuids = [p.item_uuid for p in shared_perms]
shared_uuids = list(
UserSharePermission.objects.filter(
shared_with=user,
item_type=ItemType.DATASET,
is_deleted=False,
is_enabled=True,
permission_level__in=[
PermissionLevel.CO_OWNER,
PermissionLevel.CONTRIBUTOR,
],
).values_list("item_uuid", flat=True)
)

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +180
const text =
typeof firstErrorMessage === "object"
? (firstErrorMessage.message ??
firstErrorMessage.detail ??
String(firstErrorMessage))
: String(firstErrorMessage);
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formatQuickAddSummary function treats the first error message as either a string or an object, but the errors array from the API contains strings (per line 1827: errors.append(f"{c.uuid}: {e}")). The complex object handling (firstErrorMessage.message, firstErrorMessage.detail) is unnecessary and may never execute. Either simplify the code to expect strings only, or document why object error messages might occur.

Suggested change
const text =
typeof firstErrorMessage === "object"
? (firstErrorMessage.message ??
firstErrorMessage.detail ??
String(firstErrorMessage))
: String(firstErrorMessage);
const text = String(firstErrorMessage);

Copilot uses AI. Check for mistakes.
Comment on lines +1698 to +1767
class QuickAddCapturesToDatasetView(Auth0LoginRequiredMixin, View):
"""
Quick-add view: add a single capture (and all its channels if multi-channel)
owned by the user to a dataset the user can modify.
Expects POST with JSON body:
{"dataset_uuid": "<uuid>", "capture_uuid": "<uuid>"}
Returns error if capture_uuid or dataset_uuid is missing/invalid.
Returns error if user does not have permission to add captures to the dataset.
On success returns added/skipped UUIDs and errors (skipped = already in dataset).
"""

def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse:
data, error = self._parse_json(request)
if error:
return error
assert data is not None # type narrowing: success path only

# Validate both UUIDs before any ORM access
dataset_uuid, capture_uuid, err = self._validate_quick_add_uuids(data)
if err:
return err
assert dataset_uuid is not None
assert capture_uuid is not None

# Fetch capture: must be owned by user and not deleted
capture = Capture.objects.filter(
uuid=capture_uuid,
owner=request.user,
is_deleted=False,
).first()
if not capture:
return JsonResponse(
{"error": "Capture not found"},
status=status.HTTP_404_NOT_FOUND,
)

# Dataset: must exist, not deleted, private (cannot change public datasets)
dataset = Dataset.objects.filter(
uuid=dataset_uuid,
is_deleted=False,
is_public=False,
).first()
if not dataset:
return JsonResponse(
{"error": "Dataset not found or cannot be modified"},
status=status.HTTP_404_NOT_FOUND,
)
# User must have add permission (owner, co-owner, or contributor; not viewer)
if not UserSharePermission.user_can_add_assets(
cast("User", request.user), dataset.uuid, ItemType.DATASET
):
return JsonResponse(
{"error": "You do not have permission to edit this dataset."},
status=status.HTTP_403_FORBIDDEN,
)

report = self._add_capture_to_dataset_with_report(
capture=capture,
dataset=dataset,
user=cast("User", request.user),
)
return JsonResponse(
{
"success": True,
"added": [str(u) for u in report["added"]],
"skipped": [str(u) for u in report["skipped"]],
"errors": report["errors"],
},
status=status.HTTP_200_OK,
)
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new QuickAddCapturesToDatasetView and UserDatasetsForQuickAddView endpoints lack test coverage. Based on the comprehensive test patterns in test_views.py and test_drf_views.py, tests should be added to cover scenarios including: valid single/multi-capture additions, permission checks (owner/co-owner/contributor/viewer), invalid UUIDs, non-existent captures/datasets, public dataset rejection, multi-channel capture grouping, and error handling.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seconding this. Generate the tests after refactoring so you're not testing functions that end up getting merged or deleted.

Comment on lines +1698 to +1904
class QuickAddCapturesToDatasetView(Auth0LoginRequiredMixin, View):
"""
Quick-add view: add a single capture (and all its channels if multi-channel)
owned by the user to a dataset the user can modify.
Expects POST with JSON body:
{"dataset_uuid": "<uuid>", "capture_uuid": "<uuid>"}
Returns error if capture_uuid or dataset_uuid is missing/invalid.
Returns error if user does not have permission to add captures to the dataset.
On success returns added/skipped UUIDs and errors (skipped = already in dataset).
"""

def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse:
data, error = self._parse_json(request)
if error:
return error
assert data is not None # type narrowing: success path only

# Validate both UUIDs before any ORM access
dataset_uuid, capture_uuid, err = self._validate_quick_add_uuids(data)
if err:
return err
assert dataset_uuid is not None
assert capture_uuid is not None

# Fetch capture: must be owned by user and not deleted
capture = Capture.objects.filter(
uuid=capture_uuid,
owner=request.user,
is_deleted=False,
).first()
if not capture:
return JsonResponse(
{"error": "Capture not found"},
status=status.HTTP_404_NOT_FOUND,
)

# Dataset: must exist, not deleted, private (cannot change public datasets)
dataset = Dataset.objects.filter(
uuid=dataset_uuid,
is_deleted=False,
is_public=False,
).first()
if not dataset:
return JsonResponse(
{"error": "Dataset not found or cannot be modified"},
status=status.HTTP_404_NOT_FOUND,
)
# User must have add permission (owner, co-owner, or contributor; not viewer)
if not UserSharePermission.user_can_add_assets(
cast("User", request.user), dataset.uuid, ItemType.DATASET
):
return JsonResponse(
{"error": "You do not have permission to edit this dataset."},
status=status.HTTP_403_FORBIDDEN,
)

report = self._add_capture_to_dataset_with_report(
capture=capture,
dataset=dataset,
user=cast("User", request.user),
)
return JsonResponse(
{
"success": True,
"added": [str(u) for u in report["added"]],
"skipped": [str(u) for u in report["skipped"]],
"errors": report["errors"],
},
status=status.HTTP_200_OK,
)

def _validate_quick_add_uuids(
self, data: dict[str, Any]
) -> tuple[UUID | None, UUID | None, JsonResponse | None]:
"""
Validate dataset_uuid and capture_uuid from request data.
Returns (dataset_uuid, capture_uuid, None) on success,
(None, None, error_response) on validation failure.
"""
dataset_uuid, err = self._validate_uuid(data.get("dataset_uuid"))
if err:
return None, None, err
capture_uuid, err = self._validate_uuid(data.get("capture_uuid"))
if err:
return None, None, err
return dataset_uuid, capture_uuid, None

def _add_capture_to_dataset_with_report(
self,
capture: "Capture",
dataset: "Dataset",
user: "User",
) -> dict[str, Any]:
"""
Add a single capture (and its multi-channel siblings if applicable) to a
dataset. Skips captures already in the dataset. Internal use only.

Returns a dict with keys: "added" (list of UUIDs), "skipped" (list of
UUIDs), "errors" (list of str).
"""
added: list[UUID] = []
skipped: list[UUID] = []
errors: list[str] = []

if capture.is_multi_channel:
candidates = list(
Capture.objects.filter(
top_level_dir=capture.top_level_dir,
owner=user,
is_deleted=False,
)
)
else:
candidates = [capture]

existing_pks = set(
dataset.captures.filter(pk__in=[c.pk for c in candidates]).values_list(
"pk", flat=True
)
)

for c in candidates:
if c.pk in existing_pks:
skipped.append(c.uuid)
continue
try:
dataset.captures.add(c)
added.append(c.uuid)
except OperationalError as e:
errors.append(f"{c.uuid}: {e}")
except IntegrityError as e:
errors.append(f"{c.uuid}: {e}")
except Exception as e: # noqa: BLE001 - catch-all for unexpected errors
errors.append(f"{c.uuid}: {e}")

return {"added": added, "skipped": skipped, "errors": errors}

def _parse_json(
self, request: HttpRequest
) -> tuple[dict[str, Any] | None, JsonResponse | None]:
"""
Parse request body as JSON and require it to be an object.
Returns (data, None) on success, (None, error_response) on error.
Caller should: data, err = self._parse_json(request); if err: return err
"""
if not request.content_type or "application/json" not in request.content_type:
return None, JsonResponse(
{"error": "Content-Type must be application/json"},
status=status.HTTP_400_BAD_REQUEST,
)
if not request.body:
return None, JsonResponse(
{"error": "Invalid JSON body"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return None, JsonResponse(
{"error": "Invalid JSON body"},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(data, dict):
return None, JsonResponse(
{"error": "Request body must be a JSON object"},
status=status.HTTP_400_BAD_REQUEST,
)
return data, None

def _validate_uuid(self, value: Any) -> tuple[UUID | None, JsonResponse | None]:
"""
Validate capture_uuid or dataset_uuid from request data.
Returns (uuid, None) on success,
(None, error_response) on validation failure.
"""
if value is None:
return None, JsonResponse(
{"error": "uuid is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(value, str):
return None, JsonResponse(
{"error": "uuid must be a string"},
status=status.HTTP_400_BAD_REQUEST,
)
s = value.strip()
if not s:
return None, JsonResponse(
{"error": "uuid cannot be empty"},
status=status.HTTP_400_BAD_REQUEST,
)
max_length = 64
if len(s) > max_length:
return None, JsonResponse(
{"error": "Invalid uuid"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
return UUID(s), None
except ValueError:
return None, JsonResponse(
{"error": "Invalid uuid"},
status=status.HTTP_400_BAD_REQUEST,
)


quick_add_capture_to_dataset_view = QuickAddCapturesToDatasetView.as_view()
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class is named QuickAddCapturesToDatasetView (plural "Captures") but the exported view instance is named quick_add_capture_to_dataset_view (singular "Capture"). While the view can handle both single and multiple captures, the inconsistency is confusing. Consider renaming either the class to use singular form to match the export, or update the export to use plural form.

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +160
async handleAdd() {
const datasetUuid = this.selectEl?.value;
if (!datasetUuid) return;
const isMulti =
Array.isArray(this.currentCaptureUuids) &&
this.currentCaptureUuids.length > 0;
const isSingle = this.currentCaptureUuid && this.quickAddUrl;
if (!isMulti && !isSingle) return;
if (this.confirmBtn) this.confirmBtn.disabled = true;
this.resetMessage();
if (isMulti) {
await this.handleMultiAdd(datasetUuid);
} else {
await this.handleSingleAdd(datasetUuid);
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When multi-add mode is triggered with an empty capture list (currentCaptureUuids is an empty array), the code proceeds silently without providing feedback. The handleAdd function should validate that currentCaptureUuids contains at least one capture and show an appropriate error message if empty, similar to how isSingle and isMulti are checked.

Copilot uses AI. Check for mistakes.
Comment on lines +610 to +621
setupSelectionCheckboxHandler() {
document.addEventListener("change", (e) => {
if (!e.target.matches(".capture-select-checkbox")) return;
const uuid = e.target.getAttribute("data-capture-uuid");
if (!uuid) return;
if (e.target.checked) {
this.selectedCaptureIds.add(uuid);
} else {
this.selectedCaptureIds.delete(uuid);
}
});
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setupSelectionCheckboxHandler method attaches a document-level event listener that is never removed, which could lead to memory leaks if FileListCapturesTableManager is instantiated multiple times (e.g., during testing or dynamic page updates). While this may not be a practical issue in production since the table is typically initialized once, consider storing the listener reference and providing a cleanup method for proper resource management.

Copilot uses AI. Check for mistakes.
table.classList.remove("selection-mode-active");
mainBtn.classList.remove("d-none");
mainBtn.setAttribute("aria-pressed", "false");
if (modeButtonsWrap) modeButtonsWrap.classList.add("d-none");
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When exiting selection mode, the selected checkboxes remain checked visually and in the selectedCaptureIds set. The exitSelectionMode function should clear all selections by unchecking all checkboxes and clearing the selectedCaptureIds set to prevent stale selections from persisting when the user re-enters selection mode later.

Suggested change
if (modeButtonsWrap) modeButtonsWrap.classList.add("d-none");
if (modeButtonsWrap) modeButtonsWrap.classList.add("d-none");
// Clear all selection checkboxes in the table
const checkboxes = table.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach((checkbox) => {
checkbox.checked = false;
checkbox.indeterminate = false;
});
// Clear the selected capture IDs set/collection, if present
const selectedCaptureIds = this.tableManager?.selectedCaptureIds;
if (selectedCaptureIds) {
if (typeof selectedCaptureIds.clear === "function") {
selectedCaptureIds.clear();
} else if (Array.isArray(selectedCaptureIds)) {
selectedCaptureIds.length = 0;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +1769 to +1783
def _validate_quick_add_uuids(
self, data: dict[str, Any]
) -> tuple[UUID | None, UUID | None, JsonResponse | None]:
"""
Validate dataset_uuid and capture_uuid from request data.
Returns (dataset_uuid, capture_uuid, None) on success,
(None, None, error_response) on validation failure.
"""
dataset_uuid, err = self._validate_uuid(data.get("dataset_uuid"))
if err:
return None, None, err
capture_uuid, err = self._validate_uuid(data.get("capture_uuid"))
if err:
return None, None, err
return dataset_uuid, capture_uuid, None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge this with _validate_uuid

Comment on lines +1722 to +1753
# Fetch capture: must be owned by user and not deleted
capture = Capture.objects.filter(
uuid=capture_uuid,
owner=request.user,
is_deleted=False,
).first()
if not capture:
return JsonResponse(
{"error": "Capture not found"},
status=status.HTTP_404_NOT_FOUND,
)

# Dataset: must exist, not deleted, private (cannot change public datasets)
dataset = Dataset.objects.filter(
uuid=dataset_uuid,
is_deleted=False,
is_public=False,
).first()
if not dataset:
return JsonResponse(
{"error": "Dataset not found or cannot be modified"},
status=status.HTTP_404_NOT_FOUND,
)
# User must have add permission (owner, co-owner, or contributor; not viewer)
if not UserSharePermission.user_can_add_assets(
cast("User", request.user), dataset.uuid, ItemType.DATASET
):
return JsonResponse(
{"error": "You do not have permission to edit this dataset."},
status=status.HTTP_403_FORBIDDEN,
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That first filter will return a subset of datasets: you're filtering by ownership when co-owners can also edit a dataset. These co-owned datasets will be excluded from that filter.

This whole block can likely be replaced by a call to _validate_dataset_edit_permissions(). If there's undesired behavior in this function, refactor it into something both places can use, because it's better than having code related to permission checks duplicated.

except Exception as e: # noqa: BLE001 - catch-all for unexpected errors
errors.append(f"{c.uuid}: {e}")

return {"added": added, "skipped": skipped, "errors": errors}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] dicts lack type validation and may raise KeyErrors at runtime; use a dataclass or namedtuple instead

Comment on lines +1835 to +1865
def _parse_json(
self, request: HttpRequest
) -> tuple[dict[str, Any] | None, JsonResponse | None]:
"""
Parse request body as JSON and require it to be an object.
Returns (data, None) on success, (None, error_response) on error.
Caller should: data, err = self._parse_json(request); if err: return err
"""
if not request.content_type or "application/json" not in request.content_type:
return None, JsonResponse(
{"error": "Content-Type must be application/json"},
status=status.HTTP_400_BAD_REQUEST,
)
if not request.body:
return None, JsonResponse(
{"error": "Invalid JSON body"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return None, JsonResponse(
{"error": "Invalid JSON body"},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(data, dict):
return None, JsonResponse(
{"error": "Request body must be a JSON object"},
status=status.HTTP_400_BAD_REQUEST,
)
return data, None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be unnecessary. See if we can let json.loads raise in the parent function instead. Also add the parser to the class, if requiring a JSON on every endpoint of this view:

from rest_framework.parsers import JSONParser

class ExampleView(APIView):
    ...
    parser_classes = [JSONParser]

see https://www.django-rest-framework.org/api-guide/parsers/

)
datasets = [
{"uuid": str(d["uuid"]), "name": (d["name"] or "Unnamed")}
for d in list(owned) + list(shared)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conversion to list is not needed. Use itertools.chain to avoid consuming the iterables into memory at once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway component javascript Pull requests that update non-trivial javascript code styling Special focus on styling of front-end components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants