This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new b7361af Allow multiple file selection in move, and add linting
b7361af is described below
commit b7361af3485169f2f678a659510d7efd508e0074
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue May 27 19:04:15 2025 +0100
Allow multiple file selection in move, and add linting
- Retain the incompatibility display when selecting items
- Use checkboxes and radio controls for source and destination
- Add an ESLint configuration and a package.json file
- Update linting configuration to exclude node_modules
---
.gitignore | 2 +
.pre-commit-config.yaml | 12 +-
atr/routes/finish.py | 255 +++++++++----
atr/static/js/finish-selected-move.js | 471 ++++++++++++++---------
atr/static/js/finish-selected-move.js.map | 2 +-
atr/static/ts/finish-selected-move.ts | 614 ++++++++++++++++++------------
atr/templates/finish-selected.html | 6 +-
eslint.config.mjs | 37 ++
package.json | 11 +
pyproject.toml | 5 +-
tsconfig.json | 13 +-
11 files changed, 905 insertions(+), 523 deletions(-)
diff --git a/.gitignore b/.gitignore
index f9cff25..339aa9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,5 +10,7 @@
__pycache__/
apptoken.txt
bootstrap/build/
+node_modules/
+package-lock.json
state/
atr/_version.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5ded58b..107c14d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,10 +3,15 @@ repos:
rev: v5.0.0
hooks:
- id: check-toml
+ exclude: ^node_modules/
- id: check-yaml
+ exclude: ^node_modules/
- id: end-of-file-fixer
+ exclude: ^node_modules/
- id: mixed-line-ending
+ exclude: ^node_modules/
- id: trailing-whitespace
+ exclude: ^node_modules/
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.5
hooks:
@@ -62,18 +67,21 @@ repos:
entry: ruff check --fix
language: system
types: [python]
+ exclude: ^node_modules/
- id: ruff-format
name: Ruff Formatter
entry: ruff format --force-exclude
language: system
types: [python]
+ exclude: ^node_modules/
- id: mypy
name: Mypy Type Check
entry: mypy
language: system
require_serial: true
types: [python]
- exclude: "tests" # see
https://github.com/pre-commit/pre-commit/issues/2967
+ # See https://github.com/pre-commit/pre-commit/issues/2967
+ exclude: ^(node_modules|tests)/
args:
- --config-file=pyproject.toml
- --scripts-are-modules
@@ -83,7 +91,7 @@ repos:
language: system
require_serial: true
types: [python]
- exclude: "tests"
+ exclude: ^tests/
- id: jinja-route-check
name: Jinja Route Checker
description: Check whether routes used in Jinja2 templates actually exist
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index ce3bd67..8dda9c5 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -22,8 +22,10 @@ from typing import Final
import aiofiles.os
import quart
import quart.wrappers.response as quart_response
+import werkzeug.datastructures as datastructures
import werkzeug.wrappers.response as response
import wtforms
+import wtforms.fields as fields
import atr.db as db
import atr.db.models as models
@@ -56,21 +58,32 @@ class CreateDirectoryForm(util.QuartFormTyped):
class MoveFileForm(util.QuartFormTyped):
- """Form for moving a file within a preview revision."""
+ """Form for moving one or more files within a preview revision."""
- source_file = wtforms.SelectField("File to move", choices=[],
validators=[wtforms.validators.InputRequired()])
+ source_files = wtforms.SelectMultipleField(
+ "Files to move",
+ choices=[],
+ validators=[wtforms.validators.DataRequired(message="Please select at
least one file to move.")],
+ )
target_directory = wtforms.SelectField(
- "Target directory", choices=[],
validators=[wtforms.validators.InputRequired()]
+ "Target directory", choices=[],
validators=[wtforms.validators.DataRequired()]
)
submit = wtforms.SubmitField("Move file")
+ def validate_source_files(self, field: fields.SelectMultipleField) -> None:
+ if not field.data or len(field.data) == 0:
+ raise wtforms.validators.ValidationError("Please select at least
one file to move.")
+
def validate_target_directory(self, field: wtforms.Field) -> None:
# This validation runs only if both fields have data
- if self.source_file.data and field.data:
- source_path = pathlib.Path(self.source_file.data)
+ if self.source_files.data and field.data:
+ source_paths = [pathlib.Path(sf) for sf in self.source_files.data]
target_dir = pathlib.Path(field.data)
- if source_path.parent == target_dir:
- raise wtforms.validators.ValidationError("Target directory
cannot be the same as the source directory.")
+ for source_path in source_paths:
+ if source_path.parent == target_dir:
+ raise wtforms.validators.ValidationError(
+ f"Target directory cannot be the same as the source
directory for {source_path.name}."
+ )
@routes.committer("/finish/<project_name>/<version_name>", methods=["GET",
"POST"])
@@ -105,28 +118,16 @@ async def selected(
)
# Populate choices dynamically for both GET and POST
- move_form.source_file.choices = sorted([(str(p), str(p)) for p in
source_files_rel])
+ move_form.source_files.choices = sorted([(str(p), str(p)) for p in
source_files_rel])
move_form.target_directory.choices = sorted([(str(d), str(d)) for d in
target_dirs])
can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
if formdata:
- form_action = formdata.get("form_action")
- if form_action == "create_dir":
- if await create_dir_form.validate_on_submit():
- new_dir_name = create_dir_form.new_directory_name.data
- if new_dir_name:
- return await _create_directory_in_revision(
- pathlib.Path(new_dir_name),
- session,
- project_name,
- version_name,
-
quart.request.accept_mimetypes.best_match(["application/json", "text/html"])
- == "application/json",
- )
- elif (form_action != "create_dir") and can_move:
- move_result = await _move_file(move_form, session, project_name,
version_name)
- if move_result is not None:
- return move_result
+ result = await _process_formdata(
+ formdata, session, project_name, version_name, create_dir_form,
move_form, can_move
+ )
+ if result is not None:
+ return result
# resp = await quart.current_app.make_response(template_rendered)
# resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -147,22 +148,6 @@ async def selected(
)
-async def _respond(
- session: routes.CommitterSession,
- project_name: str,
- version_name: str,
- wants_json: bool,
- ok: bool,
- msg: str,
- http_status: int = 200,
-) -> tuple[quart_response.Response, int] | response.Response:
- """Helper to respond with JSON or flash message and redirect."""
- if wants_json:
- return quart.jsonify(ok=ok, message=msg), http_status
- await quart.flash(msg, "success" if ok else "error")
- return await session.redirect(selected, project_name=project_name,
version_name=version_name)
-
-
async def _create_directory_in_revision(
new_dir_rel: pathlib.Path,
session: routes.CommitterSession,
@@ -219,38 +204,117 @@ async def _create_directory_in_revision(
)
+async def _process_formdata(
+ formdata: datastructures.MultiDict,
+ session: routes.CommitterSession,
+ project_name: str,
+ version_name: str,
+ create_dir_form: CreateDirectoryForm,
+ move_form: MoveFileForm,
+ can_move: bool,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+ form_action = formdata.get("form_action")
+
+ if (
+ (quart.request.method == "POST")
+ and ("source_files" in formdata)
+ and ("target_directory" in formdata)
+ and (not form_action)
+ ):
+ source_files_data = formdata.getlist("source_files")
+ target_dir_data = formdata.get("target_directory")
+ wants_json =
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) ==
"application/json"
+
+ if not source_files_data or not target_dir_data:
+ return await _respond(
+ session,
+ project_name,
+ version_name,
+ wants_json,
+ False,
+ "Missing source file(s) or target directory.",
+ 400,
+ )
+
+ source_files_rel = [pathlib.Path(sf) for sf in source_files_data]
+ target_dir_rel = pathlib.Path(target_dir_data)
+
+ if not source_files_rel:
+ return await _respond(
+ session, project_name, version_name, wants_json, False, "No
source files selected.", 400
+ )
+ return await _move_file_to_revision(
+ source_files_rel, target_dir_rel, session, project_name,
version_name, wants_json
+ )
+
+ elif form_action == "create_dir":
+ if await create_dir_form.validate_on_submit():
+ new_dir_name = create_dir_form.new_directory_name.data
+ if new_dir_name:
+ return await _create_directory_in_revision(
+ pathlib.Path(new_dir_name),
+ session,
+ project_name,
+ version_name,
+
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) ==
"application/json",
+ )
+
+ elif ((form_action != "create_dir") or (form_action is None)) and can_move:
+ return await _move_file(move_form, session, project_name, version_name)
+ return None
+
+
async def _move_file(
- form: MoveFileForm, session: routes.CommitterSession, project_name: str,
version_name: str
+ form: MoveFileForm,
+ session: routes.CommitterSession,
+ project_name: str,
+ version_name: str,
) -> tuple[quart_response.Response, int] | response.Response | None:
wants_json = "application/json" in quart.request.headers.get("Accept", "")
+
if await form.validate_on_submit():
- source_file_rel = pathlib.Path(form.source_file.data)
- target_dir_rel = pathlib.Path(form.target_directory.data)
+ source_files_rel_str_list = form.source_files.data
+ target_dir_rel_str = form.target_directory.data
+ if not source_files_rel_str_list or not target_dir_rel_str:
+ return await _respond(
+ session,
+ project_name,
+ version_name,
+ wants_json,
+ False,
+ "Source file(s) or target directory missing.",
+ 400,
+ )
+
+ source_files_rel = [pathlib.Path(sf_str) for sf_str in
source_files_rel_str_list]
+ target_dir_rel = pathlib.Path(target_dir_rel_str)
return await _move_file_to_revision(
- source_file_rel, target_dir_rel, session, project_name,
version_name, wants_json
+ source_files_rel, target_dir_rel, session, project_name,
version_name, wants_json
)
else:
if wants_json:
error_messages = []
for field_name, field_errors in form.errors.items():
field_object = getattr(form, field_name, None)
- label_object = getattr(field_object, "label", None)
- label_text = label_object.text if label_object else
field_name.replace("_", " ").title()
+ label_text = field_name.replace("_", " ").title()
+ if field_object and hasattr(field_object, "label") and
field_object.label:
+ label_text = field_object.label.text
error_messages.append(f"{label_text}: {',
'.join(field_errors)}")
error_string = "; ".join(error_messages)
return await _respond(session, project_name, version_name, True,
False, error_string, 400)
else:
for field_name, field_errors in form.errors.items():
field_object = getattr(form, field_name, None)
- label_object = getattr(field_object, "label", None)
- label_text = label_object.text if label_object else
field_name.replace("_", " ").title()
+ label_text = field_name.replace("_", " ").title()
+ if field_object and hasattr(field_object, "label") and
field_object.label:
+ label_text = field_object.label.text
for error_message_text in field_errors:
await quart.flash(f"{label_text}: {error_message_text}",
"warning")
return None
async def _move_file_to_revision(
- source_file_rel: pathlib.Path,
+ source_files_rel: list[pathlib.Path],
target_dir_rel: pathlib.Path,
session: routes.CommitterSession,
project_name: str,
@@ -259,27 +323,41 @@ async def _move_file_to_revision(
) -> tuple[quart_response.Response, int] | response.Response:
try:
description = "File move through web interface"
+ moved_files_names: list[str] = []
+ skipped_files_names: list[str] = []
+
async with revision.create_and_manage(
project_name, version_name, session.uid, description=description
) as creating:
- related_files = _related_files(source_file_rel)
- bundle = [f for f in related_files if await
aiofiles.os.path.exists(creating.interim_path / f)]
- collisions = [
- f.name for f in bundle if await
aiofiles.os.path.exists(creating.interim_path / target_dir_rel / f.name)
- ]
- if collisions:
- # Remove the temporary directory, and do not create or commit
a new revision
- # (But also do not raise an exception)
- creating.failed = True
- msg = f"Files already exist in '{target_dir_rel}': {',
'.join(collisions)}"
- return await _respond(session, project_name, version_name,
wants_json, False, msg, 400)
-
- for f in bundle:
- await aiofiles.os.rename(creating.interim_path / f,
creating.interim_path / target_dir_rel / f.name)
+ await _setup_revision(
+ source_files_rel,
+ target_dir_rel,
+ creating,
+ moved_files_names,
+ skipped_files_names,
+ )
+
+ if creating.failed:
+ return await _respond(
+ session, project_name, version_name, wants_json, False, "Move
operation failed due to pre-check.", 409
+ )
+
+ response_messages = []
+ if moved_files_names:
+ response_messages.append(f"Moved {', '.join(moved_files_names)}")
+ if skipped_files_names:
+ response_messages.append(f"Skipped {',
'.join(skipped_files_names)} (already in target directory)")
+
+ if not response_messages:
+ if not source_files_rel:
+ return await _respond(
+ session, project_name, version_name, wants_json, False,
"No source files specified for move.", 400
+ )
+ msg = f"No files were moved. {', '.join(skipped_files_names)}
already in '{target_dir_rel}'."
+ return await _respond(session, project_name, version_name,
wants_json, True, msg, 200)
- return await _respond(
- session, project_name, version_name, wants_json, True, f"Moved {',
'.join(f.name for f in bundle)}", 200
- )
+ final_msg = ". ".join(response_messages) + "."
+ return await _respond(session, project_name, version_name, wants_json,
True, final_msg, 200)
except FileNotFoundError:
_LOGGER.exception("File not found during move operation in new
revision")
@@ -314,6 +392,49 @@ def _related_files(path: pathlib.Path) ->
list[pathlib.Path]:
]
+async def _respond(
+ session: routes.CommitterSession,
+ project_name: str,
+ version_name: str,
+ wants_json: bool,
+ ok: bool,
+ msg: str,
+ http_status: int = 200,
+) -> tuple[quart_response.Response, int] | response.Response:
+ """Helper to respond with JSON or flash message and redirect."""
+ if wants_json:
+ return quart.jsonify(ok=ok, message=msg), http_status
+ await quart.flash(msg, "success" if ok else "error")
+ return await session.redirect(selected, project_name=project_name,
version_name=version_name)
+
+
+async def _setup_revision(
+ source_files_rel: list[pathlib.Path],
+ target_dir_rel: pathlib.Path,
+ creating: revision.Creating,
+ moved_files_names: list[str],
+ skipped_files_names: list[str],
+) -> None:
+ for source_file_rel in source_files_rel:
+ if source_file_rel.parent == target_dir_rel:
+ skipped_files_names.append(source_file_rel.name)
+ continue
+
+ related_files = _related_files(source_file_rel)
+ bundle = [f for f in related_files if await
aiofiles.os.path.exists(creating.interim_path / f)]
+ collisions = [
+ f.name for f in bundle if await
aiofiles.os.path.exists(creating.interim_path / target_dir_rel / f.name)
+ ]
+ if collisions:
+ creating.failed = True
+ return
+
+ for f in bundle:
+ await aiofiles.os.rename(creating.interim_path / f,
creating.interim_path / target_dir_rel / f.name)
+ if f == source_file_rel:
+ moved_files_names.append(f.name)
+
+
async def _sources_and_targets(latest_revision_dir: pathlib.Path) ->
tuple[list[pathlib.Path], set[pathlib.Path]]:
source_files_rel: list[pathlib.Path] = []
target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
diff --git a/atr/static/js/finish-selected-move.js
b/atr/static/js/finish-selected-move.js
index bcd1a58..c35a6ad 100644
--- a/atr/static/js/finish-selected-move.js
+++ b/atr/static/js/finish-selected-move.js
@@ -13,24 +13,29 @@ var ItemType;
ItemType["File"] = "file";
ItemType["Dir"] = "dir";
})(ItemType || (ItemType = {}));
-const CONFIRM_MOVE_BUTTON_ID = "confirm-move-button";
-const CURRENT_MOVE_SELECTION_INFO_ID = "current-move-selection-info";
-const DIR_DATA_ID = "dir-data";
-const DIR_FILTER_INPUT_ID = "dir-filter-input";
-const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
-const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
-const ERROR_ALERT_ID = "move-error-alert";
-const FILE_DATA_ID = "file-data";
-const FILE_FILTER_ID = "file-filter";
-const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
-const FILE_LIST_TABLE_BODY_ID = "file-list-table-body";
-const MAIN_SCRIPT_DATA_ID = "main-script-data";
-const MAX_FILES_INPUT_ID = "max-files-input";
-const SELECTED_FILE_NAME_TITLE_ID = "selected-file-name-title";
-const TXT_CHOOSE = "Choose";
-const TXT_CHOSEN = "Chosen";
-const TXT_SELECT = "Select";
-const TXT_SELECTED = "Selected";
+const ID = Object.freeze({
+ confirmMoveButton: "confirm-move-button",
+ currentMoveSelectionInfo: "current-move-selection-info",
+ dirData: "dir-data",
+ dirFilterInput: "dir-filter-input",
+ dirListMoreInfo: "dir-list-more-info",
+ dirListTableBody: "dir-list-table-body",
+ errorAlert: "move-error-alert",
+ fileData: "file-data",
+ fileFilter: "file-filter",
+ fileListMoreInfo: "file-list-more-info",
+ fileListTableBody: "file-list-table-body",
+ mainScriptData: "main-script-data",
+ maxFilesInput: "max-files-input",
+ selectedFileNameTitle: "selected-file-name-title",
+});
+const TXT = Object.freeze({
+ Choose: "Choose",
+ Chosen: "Chosen",
+ Select: "Select",
+ Selected: "Selected",
+ MoreItemsHint: "more available (filter to browse)..."
+});
const MAX_FILES_FALLBACK = 5;
let fileFilterInput;
let fileListTableBody;
@@ -42,6 +47,14 @@ let confirmMoveButton;
let currentMoveSelectionInfoElement;
let errorAlert;
let uiState;
+function toLower(s) {
+ return (s || "").toLocaleLowerCase();
+}
+function includesCaseInsensitive(haystack, needle) {
+ if (haystack === null || haystack === undefined || needle === null ||
needle === undefined)
+ return false;
+ return toLower(haystack).includes(toLower(needle));
+}
function getParentPath(filePathString) {
if (!filePathString || typeof filePathString !== "string")
return ".";
@@ -52,84 +65,109 @@ function getParentPath(filePathString) {
return "/";
return filePathString.substring(0, lastSlash);
}
-const toLower = (s) => (s || "").toLocaleLowerCase();
-const includesCaseInsensitive = (haystack, lowerNeedle) =>
toLower(haystack).includes(lowerNeedle);
function assertElementPresent(element, selector) {
if (!element) {
throw new Error(`Required DOM element '${selector}' not found.`);
}
return element;
}
+function $(id) {
+ return assertElementPresent(document.getElementById(id), id);
+}
function updateMoveSelectionInfo() {
if (selectedFileNameTitleElement) {
- selectedFileNameTitleElement.textContent =
uiState.currentlySelectedFilePath
- ? `Select a destination for ${uiState.currentlySelectedFilePath}`
+ selectedFileNameTitleElement.textContent =
uiState.currentlySelectedFilePaths.size > 0
+ ? `Select a destination for
${uiState.currentlySelectedFilePaths.size} file(s)`
: "Select a destination for the file";
}
- let infoHTML = "";
- let disableConfirm = true;
- if (!uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath) {
- infoHTML = `Selected destination:
<strong>${uiState.currentlyChosenDirectoryPath}</strong>. Please select a file
to move.`;
+ const numSelectedFiles = uiState.currentlySelectedFilePaths.size;
+ const destinationDir = uiState.currentlyChosenDirectoryPath;
+ let message = "Please select files and a destination.";
+ let disableConfirmButton = true;
+ currentMoveSelectionInfoElement.innerHTML = '';
+ if (!numSelectedFiles && destinationDir) {
+
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Selected
destination: "));
+ const strongDest = document.createElement("strong");
+ strongDest.textContent = destinationDir;
+ currentMoveSelectionInfoElement.appendChild(strongDest);
+ currentMoveSelectionInfoElement.appendChild(document.createTextNode(".
Please select file(s) to move."));
}
- else if (uiState.currentlySelectedFilePath &&
!uiState.currentlyChosenDirectoryPath) {
- infoHTML = `Moving
<strong>${uiState.currentlySelectedFilePath}</strong> to (select destination).`;
+ else if (numSelectedFiles && !destinationDir) {
+
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Moving "));
+ const strongN = document.createElement("strong");
+ strongN.textContent = `${numSelectedFiles} file(s)`;
+ currentMoveSelectionInfoElement.appendChild(strongN);
+ currentMoveSelectionInfoElement.appendChild(document.createTextNode("
to (select destination)."));
}
- else if (uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath) {
- infoHTML = `Move <strong>${uiState.currentlySelectedFilePath}</strong>
to <strong>${uiState.currentlyChosenDirectoryPath}</strong>`;
- disableConfirm = false;
+ else if (numSelectedFiles && destinationDir) {
+ const filesArray = Array.from(uiState.currentlySelectedFilePaths);
+ const displayFiles = filesArray.length > 1 ? `${filesArray[0]} and
${filesArray.length - 1} other(s)` : filesArray[0];
+
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Move "));
+ const strongDisplayFiles = document.createElement("strong");
+ strongDisplayFiles.textContent = displayFiles;
+ currentMoveSelectionInfoElement.appendChild(strongDisplayFiles);
+ currentMoveSelectionInfoElement.appendChild(document.createTextNode("
to "));
+ const strongDest = document.createElement("strong");
+ strongDest.textContent = destinationDir;
+ currentMoveSelectionInfoElement.appendChild(strongDest);
+ message = "";
+ disableConfirmButton = false;
}
- else {
- infoHTML = "Please select a file and a destination.";
+ if (message && currentMoveSelectionInfoElement.childNodes.length === 0) {
+ currentMoveSelectionInfoElement.textContent = message;
}
- currentMoveSelectionInfoElement.innerHTML = infoHTML;
- confirmMoveButton.disabled = disableConfirm;
+ confirmMoveButton.disabled = disableConfirmButton;
}
function renderListItems(tbodyElement, items, config) {
const fragment = new DocumentFragment();
const itemsToShow = items.slice(0, uiState.maxFilesToShow);
itemsToShow.forEach(item => {
- const itemPathString = config.itemType === ItemType.Dir && !item ? "."
: String(item || "");
+ const itemPathString = (config.itemType === ItemType.Dir && !item) ?
"." : String(item || "");
const row = document.createElement("tr");
row.className = "page-table-row-interactive";
- const buttonCell = row.insertCell();
- buttonCell.className = "page-table-button-cell text-end";
+ const controlCell = row.insertCell();
+ controlCell.className = "page-table-button-cell text-end";
const pathCell = row.insertCell();
pathCell.className = "page-table-path-cell";
const span = document.createElement("span");
span.className = "page-file-select-text";
span.textContent = itemPathString;
- let isIncompatible = false;
- if (config.itemType === ItemType.File) {
- if (uiState.currentlyChosenDirectoryPath &&
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
- isIncompatible = true;
+ switch (config.itemType) {
+ case ItemType.File: {
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.className = "form-check-input ms-2";
+ checkbox.dataset.filePath = itemPathString;
+ checkbox.checked =
uiState.currentlySelectedFilePaths.has(itemPathString);
+ checkbox.setAttribute("aria-label", `Select file
${itemPathString}`);
+ if (uiState.currentlyChosenDirectoryPath &&
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
+ checkbox.disabled = true;
+ span.classList.add("page-extra-muted");
+ }
+ controlCell.appendChild(checkbox);
+ break;
}
- }
- else {
- if (uiState.currentlySelectedFilePath &&
getParentPath(uiState.currentlySelectedFilePath) === itemPathString) {
- isIncompatible = true;
+ case ItemType.Dir: {
+ const radio = document.createElement("input");
+ radio.type = "radio";
+ radio.name = "target-directory-radio";
+ radio.className = "form-check-input ms-2";
+ radio.value = itemPathString;
+ radio.dataset.dirPath = itemPathString;
+ radio.checked = itemPathString === config.selectedItem;
+ radio.setAttribute("aria-label", `Choose directory
${itemPathString}`);
+ if (itemPathString === config.selectedItem) {
+ row.classList.add("page-item-selected");
+ row.setAttribute("aria-selected", "true");
+ span.classList.add("fw-bold");
+ }
+ else {
+ row.setAttribute("aria-selected", "false");
+ }
+ controlCell.appendChild(radio);
+ break;
}
}
- if (isIncompatible) {
- span.classList.add("page-extra-muted");
- }
- if (itemPathString === config.selectedItem) {
- row.classList.add("page-item-selected");
- row.setAttribute("aria-selected", "true");
- span.classList.add("fw-bold");
- const arrowSpan = document.createElement("span");
- arrowSpan.className = "text-success fs-1";
- arrowSpan.textContent = "→";
- buttonCell.appendChild(arrowSpan);
- }
- else {
- row.setAttribute("aria-selected", "false");
- const button = document.createElement("button");
- button.type = "button";
- button.className = `btn btn-sm ${config.buttonClassBase}
${config.buttonClassOutline}`;
- button.dataset[config.itemType === ItemType.File ? "filePath" :
"dirPath"] = itemPathString;
- button.textContent = config.buttonTextDefault;
- buttonCell.appendChild(button);
- }
pathCell.appendChild(span);
fragment.appendChild(row);
});
@@ -137,7 +175,7 @@ function renderListItems(tbodyElement, items, config) {
const moreInfoElement = document.getElementById(config.moreInfoId);
if (moreInfoElement) {
if (items.length > uiState.maxFilesToShow) {
- moreInfoElement.textContent = `${items.length -
uiState.maxFilesToShow} more available (filter to browse)...`;
+ moreInfoElement.textContent = `${items.length -
uiState.maxFilesToShow} ${TXT.MoreItemsHint}`;
moreInfoElement.style.display = "block";
}
else {
@@ -147,184 +185,241 @@ function renderListItems(tbodyElement, items, config) {
}
}
function renderAllLists() {
- const lowerFileFilter = toLower(uiState.fileFilter);
- const filteredFilePaths = uiState.originalFilePaths.filter(fp =>
includesCaseInsensitive(fp, lowerFileFilter));
+ const filteredFilePaths = uiState.originalFilePaths.filter(fp =>
includesCaseInsensitive(fp, uiState.filters.file));
const filesConfig = {
itemType: ItemType.File,
- selectedItem: uiState.currentlySelectedFilePath,
- buttonClassBase: "select-file-btn",
- buttonClassOutline: "btn-outline-primary",
- buttonClassActive: "btn-primary",
- buttonTextSelected: TXT_SELECTED,
- buttonTextDefault: TXT_SELECT,
- moreInfoId: FILE_LIST_MORE_INFO_ID
+ selectedItem: null,
+ moreInfoId: ID.fileListMoreInfo
};
renderListItems(fileListTableBody, filteredFilePaths, filesConfig);
- const lowerDirFilter = toLower(uiState.dirFilter);
- const filteredDirs = uiState.allTargetDirs.filter(dirP =>
includesCaseInsensitive(dirP, lowerDirFilter));
+ const filteredDirs = uiState.allTargetDirs.filter(dirP =>
includesCaseInsensitive(dirP, uiState.filters.dir));
const dirsConfig = {
itemType: ItemType.Dir,
selectedItem: uiState.currentlyChosenDirectoryPath,
- buttonClassBase: "choose-dir-btn",
- buttonClassOutline: "btn-outline-secondary",
- buttonClassActive: "btn-secondary",
- buttonTextSelected: TXT_CHOSEN,
- buttonTextDefault: TXT_CHOOSE,
- moreInfoId: DIR_LIST_MORE_INFO_ID
+ moreInfoId: ID.dirListMoreInfo
};
renderListItems(dirListTableBody, filteredDirs, dirsConfig);
updateMoveSelectionInfo();
}
-function handleFileSelection(filePath) {
- if (uiState.currentlyChosenDirectoryPath) {
- const parentOfNewFile = getParentPath(filePath);
- if (parentOfNewFile === uiState.currentlyChosenDirectoryPath) {
- uiState.currentlyChosenDirectoryPath = null;
- }
- }
- uiState.currentlySelectedFilePath = filePath;
- renderAllLists();
-}
function handleDirSelection(dirPath) {
- if (dirPath && uiState.currentlySelectedFilePath &&
getParentPath(uiState.currentlySelectedFilePath) === dirPath)
- uiState.currentlySelectedFilePath = null;
uiState.currentlyChosenDirectoryPath = dirPath;
renderAllLists();
}
-function onFileListClick(event) {
- const targetElement = event.target;
- const button = targetElement.closest("button.select-file-btn");
- if (button && !button.disabled) {
- const filePath = button.dataset.filePath || null;
- handleFileSelection(filePath);
+function delegate(parent, selector, handler) {
+ parent.addEventListener("click", (e) => {
+ const targetElement = e.target;
+ if (targetElement) {
+ const el = targetElement.closest(selector);
+ if (el instanceof HTMLElement) {
+ handler(el, e);
+ }
+ }
+ });
+}
+function handleFileCheckbox(checkbox) {
+ const filePath = checkbox.dataset.filePath;
+ if (filePath) {
+ if (checkbox.checked) {
+ uiState.currentlySelectedFilePaths.add(filePath);
+ }
+ else {
+ uiState.currentlySelectedFilePaths.delete(filePath);
+ }
+ renderAllLists();
}
}
-function onDirListClick(event) {
- const targetElement = event.target;
- const button = targetElement.closest("button.choose-dir-btn");
- if (button && !button.disabled) {
- const dirPath = button.dataset.dirPath || null;
+function handleDirRadio(radio) {
+ if (radio.checked) {
+ const dirPath = radio.dataset.dirPath || null;
handleDirSelection(dirPath);
}
}
-function onFileFilterInput(event) {
- uiState.fileFilter = event.target.value;
+function setState(partial) {
+ uiState = Object.assign(Object.assign({}, uiState), partial);
renderAllLists();
}
+function onFileFilterInput(event) {
+ const target = event.target;
+ if (target instanceof HTMLInputElement) {
+ setState({ filters: Object.assign(Object.assign({}, uiState.filters),
{ file: target.value }) });
+ }
+}
function onDirFilterInput(event) {
- uiState.dirFilter = event.target.value;
- renderAllLists();
+ const target = event.target;
+ if (target instanceof HTMLInputElement) {
+ setState({ filters: Object.assign(Object.assign({}, uiState.filters),
{ dir: target.value }) });
+ }
}
function onMaxFilesChange(event) {
- const newValue = parseInt(event.target.value, 10);
+ const target = event.target;
+ const newValue = parseInt(target.value, 10);
if (newValue >= 1) {
- uiState.maxFilesToShow = newValue;
- renderAllLists();
+ setState({ maxFilesToShow: newValue });
}
else {
- event.target.value = String(uiState.maxFilesToShow);
+ target.value = String(uiState.maxFilesToShow);
}
}
-function onConfirmMoveClick() {
+function isErrorResponse(data) {
+ return typeof data === 'object' && data !== null &&
+ (('message' in data && typeof data.message === 'string') ||
+ ('error' in data && typeof data.error === 'string'));
+}
+function moveFiles(files, dest, csrfToken, signal) {
return __awaiter(this, void 0, void 0, function* () {
- errorAlert.classList.add("d-none");
- if (uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
- const formData = new FormData();
- formData.append("csrf_token", uiState.csrfToken);
- formData.append("source_file", uiState.currentlySelectedFilePath);
- formData.append("target_directory",
uiState.currentlyChosenDirectoryPath);
- try {
- const response = yield fetch(window.location.pathname, {
- method: "POST",
- body: formData,
- credentials: "same-origin",
- headers: {
- "Accept": "application/json",
- },
- });
- if (response.ok) {
- window.location.reload();
+ const formData = new FormData();
+ formData.append("csrf_token", csrfToken);
+ for (const file of files) {
+ formData.append("source_files", file);
+ }
+ formData.append("target_directory", dest);
+ try {
+ const response = yield fetch(window.location.pathname, {
+ method: "POST",
+ body: formData,
+ credentials: "same-origin",
+ headers: {
+ "Accept": "application/json",
+ },
+ signal,
+ });
+ if (response.ok) {
+ return { ok: true };
+ }
+ else {
+ let errorMsg = `An error occurred while moving the file
(Status: ${response.status})`;
+ if (response.status === 403)
+ errorMsg = "Permission denied to move the file.";
+ if (response.status === 400)
+ errorMsg = "Invalid request to move the file.";
+ if (signal === null || signal === void 0 ? void 0 :
signal.aborted) {
+ errorMsg = "Move operation aborted.";
}
- else {
- let errorMsg = `An error occurred while moving the file
(Status: ${response.status})`;
- if (response.status === 403)
- errorMsg = "Permission denied to move the file.";
- if (response.status === 400)
- errorMsg = "Invalid request to move the file.";
- try {
- const errorData = yield response.json();
- if (errorData && typeof errorData.error === "string")
+ try {
+ const errorData = yield response.json();
+ if (isErrorResponse(errorData)) {
+ if (errorData.message) {
+ errorMsg = errorData.message;
+ }
+ else if (errorData.error) {
errorMsg = errorData.error;
+ }
}
- catch (_) { }
- errorAlert.textContent = errorMsg;
- errorAlert.classList.remove("d-none");
- return;
}
+ catch ( /* Do nothing */_a) { /* Do nothing */ }
+ return { ok: false, message: errorMsg };
+ }
+ }
+ catch (error) {
+ // console.error("Network or fetch error:", error);
+ if (error instanceof Error && error.name === 'AbortError') {
+ return { ok: false, message: "Move operation aborted." };
+ }
+ return { ok: false, message: "A network error occurred. Please
check your connection and try again." };
+ }
+ });
+}
+function splitMoveCandidates(selected, dest) {
+ const toMoveMutable = [];
+ const alreadyThereMutable = [];
+ for (const filePath of selected) {
+ if (getParentPath(filePath) === dest) {
+ alreadyThereMutable.push(filePath);
+ }
+ else {
+ toMoveMutable.push(filePath);
+ }
+ }
+ return { toMove: toMoveMutable, alreadyThere: alreadyThereMutable };
+}
+function onConfirmMoveClick() {
+ return __awaiter(this, void 0, void 0, function* () {
+ errorAlert.classList.add("d-none");
+ errorAlert.textContent = "";
+ const controller = new AbortController();
+ window.addEventListener("beforeunload", () => controller.abort());
+ if (uiState.currentlySelectedFilePaths.size > 0 &&
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
+ const { toMove, alreadyThere: filesAlreadyInDest } =
splitMoveCandidates(uiState.currentlySelectedFilePaths,
uiState.currentlyChosenDirectoryPath);
+ if (toMove.length === 0 && filesAlreadyInDest.length > 0 &&
uiState.currentlySelectedFilePaths.size > 0) {
+ errorAlert.classList.remove("d-none");
+ errorAlert.textContent = `All selected files
(${filesAlreadyInDest.join(", ")}) are already in the target directory. No
files were moved.`;
+ confirmMoveButton.disabled = false;
+ return;
+ }
+ if (filesAlreadyInDest.length > 0) {
+ const alreadyInDestMsg = `Note: ${filesAlreadyInDest.join(",
")} ${filesAlreadyInDest.length === 1 ? "is" : "are"} already in the target
directory and will not be moved.`;
+ const existingError = errorAlert.textContent;
+ errorAlert.textContent = existingError ? `${existingError}
${alreadyInDestMsg}` : alreadyInDestMsg;
}
- catch (error) {
- console.error("Network or fetch error:", error);
- errorAlert.textContent = "A network error occurred. Please
check your connection and try again.";
+ const result = yield moveFiles(toMove,
uiState.currentlyChosenDirectoryPath, uiState.csrfToken, controller.signal);
+ if (result.ok) {
+ window.location.reload();
+ }
+ else {
errorAlert.classList.remove("d-none");
+ errorAlert.textContent = result.message;
}
}
else {
- errorAlert.textContent = "Please select both a file to move and a
destination directory.";
errorAlert.classList.remove("d-none");
+ errorAlert.textContent = "Please select file(s) and a destination
directory.";
}
});
}
-document.addEventListener("DOMContentLoaded", function () {
- fileFilterInput =
assertElementPresent(document.querySelector(`#${FILE_FILTER_ID}`),
FILE_FILTER_ID);
- fileListTableBody =
assertElementPresent(document.querySelector(`#${FILE_LIST_TABLE_BODY_ID}`),
FILE_LIST_TABLE_BODY_ID);
- maxFilesInput =
assertElementPresent(document.querySelector(`#${MAX_FILES_INPUT_ID}`),
MAX_FILES_INPUT_ID);
- selectedFileNameTitleElement =
assertElementPresent(document.getElementById(SELECTED_FILE_NAME_TITLE_ID),
SELECTED_FILE_NAME_TITLE_ID);
- dirFilterInput =
assertElementPresent(document.querySelector(`#${DIR_FILTER_INPUT_ID}`),
DIR_FILTER_INPUT_ID);
- dirListTableBody =
assertElementPresent(document.querySelector(`#${DIR_LIST_TABLE_BODY_ID}`),
DIR_LIST_TABLE_BODY_ID);
- confirmMoveButton =
assertElementPresent(document.querySelector(`#${CONFIRM_MOVE_BUTTON_ID}`),
CONFIRM_MOVE_BUTTON_ID);
- currentMoveSelectionInfoElement =
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID),
CURRENT_MOVE_SELECTION_INFO_ID);
+document.addEventListener("DOMContentLoaded", () => {
+ var _a, _b, _c, _d;
+ fileFilterInput = $(ID.fileFilter);
+ fileListTableBody = $(ID.fileListTableBody);
+ maxFilesInput = $(ID.maxFilesInput);
+ selectedFileNameTitleElement = $(ID.selectedFileNameTitle);
+ dirFilterInput = $(ID.dirFilterInput);
+ dirListTableBody = $(ID.dirListTableBody);
+ confirmMoveButton = $(ID.confirmMoveButton);
+ currentMoveSelectionInfoElement = $(ID.currentMoveSelectionInfo);
currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
- errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID),
ERROR_ALERT_ID);
+ errorAlert = $(ID.errorAlert);
let initialFilePaths = [];
let initialTargetDirs = [];
try {
- const fileDataElement = document.getElementById(FILE_DATA_ID);
- if (fileDataElement === null || fileDataElement === void 0 ? void 0 :
fileDataElement.textContent) {
- initialFilePaths = JSON.parse(fileDataElement.textContent);
- }
- const dirDataElement = document.getElementById(DIR_DATA_ID);
- if (dirDataElement === null || dirDataElement === void 0 ? void 0 :
dirDataElement.textContent) {
- initialTargetDirs = JSON.parse(dirDataElement.textContent);
- }
- }
- catch (e) {
- console.error("Error parsing JSON data:", e);
+ const fileData = (_a = document.getElementById(ID.fileData)) === null
|| _a === void 0 ? void 0 : _a.textContent;
+ if (fileData)
+ initialFilePaths = JSON.parse(fileData);
+ const dirData = (_b = document.getElementById(ID.dirData)) === null ||
_b === void 0 ? void 0 : _b.textContent;
+ if (dirData)
+ initialTargetDirs = JSON.parse(dirData);
}
- if (initialFilePaths.length === 0 && initialTargetDirs.length === 0) {
- alert("Warning: File and/or directory lists could not be loaded or are
empty.");
+ catch (_e) {
+ // console.error("Error parsing JSON data:");
}
- const mainScriptDataElement =
document.querySelector(`#${MAIN_SCRIPT_DATA_ID}`);
- const initialCsrfToken = (mainScriptDataElement === null ||
mainScriptDataElement === void 0 ? void 0 :
mainScriptDataElement.dataset.csrfToken) || null;
+ const csrfToken = (_d = (_c = document
+ .querySelector(`#${ID.mainScriptData}`)) === null || _c === void 0 ?
void 0 : _c.dataset.csrfToken) !== null && _d !== void 0 ? _d : null;
uiState = {
- fileFilter: fileFilterInput.value || "",
- dirFilter: dirFilterInput.value || "",
- maxFilesToShow: parseInt(maxFilesInput.value, 10) ||
MAX_FILES_FALLBACK,
- currentlySelectedFilePath: null,
+ filters: {
+ file: fileFilterInput.value || "",
+ dir: dirFilterInput.value || "",
+ },
+ maxFilesToShow: Math.max(parseInt(maxFilesInput.value, 10) || 0, 1) ||
MAX_FILES_FALLBACK,
+ currentlySelectedFilePaths: new Set(),
currentlyChosenDirectoryPath: null,
originalFilePaths: initialFilePaths,
allTargetDirs: initialTargetDirs,
- csrfToken: initialCsrfToken,
+ csrfToken,
};
- if (isNaN(uiState.maxFilesToShow) || uiState.maxFilesToShow < 1) {
- uiState.maxFilesToShow = MAX_FILES_FALLBACK;
- maxFilesInput.value = String(uiState.maxFilesToShow);
- }
+ maxFilesInput.value = String(uiState.maxFilesToShow);
fileFilterInput.addEventListener("input", onFileFilterInput);
dirFilterInput.addEventListener("input", onDirFilterInput);
maxFilesInput.addEventListener("change", onMaxFilesChange);
- fileListTableBody.addEventListener("click", onFileListClick);
- dirListTableBody.addEventListener("click", onDirListClick);
- confirmMoveButton.addEventListener("click", onConfirmMoveClick);
+ delegate(fileListTableBody, "input[type='checkbox'][data-file-path]",
handleFileCheckbox);
+ delegate(dirListTableBody,
"input[type='radio'][name='target-directory-radio']", handleDirRadio);
+ confirmMoveButton.addEventListener("click", () => {
+ onConfirmMoveClick().catch(_err => {
+ // console.error("Error in onConfirmMoveClick handler:", _err);
+ if (errorAlert) {
+ errorAlert.classList.remove("d-none");
+ errorAlert.textContent = "An unexpected error occurred. Please
try again.";
+ }
+ });
+ });
renderAllLists();
});
//# sourceMappingURL=finish-selected-move.js.map
diff --git a/atr/static/js/finish-selected-move.js.map
b/atr/static/js/finish-selected-move.js.map
index 6016ed5..2887f6f 100644
--- a/atr/static/js/finish-selected-move.js.map
+++ b/atr/static/js/finish-selected-move.js.map
@@ -1 +1 @@
-{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;;;;;;;;;;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,sBAAsB,GAAG,qBAAqB,CAAC;AACrD,MAAM,8BAA8B,GAAG,6BAA6B,CAAC;AACrE,MAAM,WAAW,GAAG,UAAU,CAAC;AAC/B,MAAM,mBAAmB,GAAG,kBAAkB,CAAC;AAC/C,MAAM,qBAAqB,GAAG,oBAAoB,CAAC;AACnD,MAAM,sBAAsB,GAAG,qBAAqB,CAAC;AACrD,MAAM,cAAc,GAAG,kBAAkB,CAAC;AAC1
[...]
+{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;;;;;;;;;;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IACrB,iBAAiB,EAAE,qBAAqB;IACxC,wBAAwB,EAAE,6BAA6B;IACvD,OAAO,EAAE,UAAU;IACnB,cAAc,EAAE,kBAAkB;IAClC,eAAe,EAAE,oBAAoB;IACrC,gBAAgB,EAAE,qBAAqB;IACvC,UAAU,EAAE,kBAAkB;IAC9B,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE,
[...]
diff --git a/atr/static/ts/finish-selected-move.ts
b/atr/static/ts/finish-selected-move.ts
index f54d6fd..c5089d7 100644
--- a/atr/static/ts/finish-selected-move.ts
+++ b/atr/static/ts/finish-selected-move.ts
@@ -5,75 +5,71 @@ enum ItemType {
Dir = "dir",
}
-const CONFIRM_MOVE_BUTTON_ID = "confirm-move-button";
-const CURRENT_MOVE_SELECTION_INFO_ID = "current-move-selection-info";
-const DIR_DATA_ID = "dir-data";
-const DIR_FILTER_INPUT_ID = "dir-filter-input";
-const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
-const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
-const ERROR_ALERT_ID = "move-error-alert";
-const FILE_DATA_ID = "file-data";
-const FILE_FILTER_ID = "file-filter";
-const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
-const FILE_LIST_TABLE_BODY_ID = "file-list-table-body";
-const MAIN_SCRIPT_DATA_ID = "main-script-data";
-const MAX_FILES_INPUT_ID = "max-files-input";
-const SELECTED_FILE_NAME_TITLE_ID = "selected-file-name-title";
-
-const TXT_CHOOSE = "Choose";
-const TXT_CHOSEN = "Chosen";
-const TXT_SELECT = "Select";
-const TXT_SELECTED = "Selected";
+const ID = Object.freeze({
+ confirmMoveButton: "confirm-move-button",
+ currentMoveSelectionInfo: "current-move-selection-info",
+ dirData: "dir-data",
+ dirFilterInput: "dir-filter-input",
+ dirListMoreInfo: "dir-list-more-info",
+ dirListTableBody: "dir-list-table-body",
+ errorAlert: "move-error-alert",
+ fileData: "file-data",
+ fileFilter: "file-filter",
+ fileListMoreInfo: "file-list-more-info",
+ fileListTableBody: "file-list-table-body",
+ mainScriptData: "main-script-data",
+ maxFilesInput: "max-files-input",
+ selectedFileNameTitle: "selected-file-name-title",
+} as const);
+
+const TXT = Object.freeze({
+ Choose: "Choose",
+ Chosen: "Chosen",
+ Select: "Select",
+ Selected: "Selected",
+ MoreItemsHint: "more available (filter to browse)..."
+} as const);
const MAX_FILES_FALLBACK = 5;
-interface ButtonDataset extends DOMStringMap {
- filePath?: string;
- dirPath?: string;
-}
-
-type ButtonClickEvent = MouseEvent & {
- currentTarget: HTMLButtonElement & { dataset: ButtonDataset };
-};
-
-type FilterInputEvent = Event & {
- target: HTMLInputElement;
-};
-
interface UIState {
- fileFilter: string;
- dirFilter: string;
+ filters: {
+ file: string;
+ dir: string;
+ };
maxFilesToShow: number;
- currentlySelectedFilePath: string | null;
+ currentlySelectedFilePaths: Set<string>;
currentlyChosenDirectoryPath: string | null;
- originalFilePaths: string[];
- allTargetDirs: string[];
+ readonly originalFilePaths: readonly string[];
+ readonly allTargetDirs: readonly string[];
csrfToken: string | null;
}
-interface RenderListDisplayConfig {
- itemType: ItemType;
- selectedItem: string | null;
- buttonClassBase: string;
- buttonClassOutline: string;
- buttonClassActive: string;
- buttonTextSelected: string;
- buttonTextDefault: string;
- moreInfoId: string;
-}
+type RenderListDisplayConfig =
+ | { itemType: ItemType.File; selectedItem: null; moreInfoId: string }
+ | { itemType: ItemType.Dir; selectedItem: string | null; moreInfoId:
string };
-let fileFilterInput: HTMLInputElement;
-let fileListTableBody: HTMLTableSectionElement;
-let maxFilesInput: HTMLInputElement;
-let selectedFileNameTitleElement: HTMLElement;
-let dirFilterInput: HTMLInputElement;
-let dirListTableBody: HTMLTableSectionElement;
-let confirmMoveButton: HTMLButtonElement;
-let currentMoveSelectionInfoElement: HTMLElement;
-let errorAlert: HTMLElement;
+let fileFilterInput!: HTMLInputElement;
+let fileListTableBody!: HTMLTableSectionElement;
+let maxFilesInput!: HTMLInputElement;
+let selectedFileNameTitleElement!: HTMLElement;
+let dirFilterInput!: HTMLInputElement;
+let dirListTableBody!: HTMLTableSectionElement;
+let confirmMoveButton!: HTMLButtonElement;
+let currentMoveSelectionInfoElement!: HTMLElement;
+let errorAlert!: HTMLElement;
let uiState: UIState;
+function toLower(s: string | null | undefined): string {
+ return (s || "").toLocaleLowerCase();
+}
+
+function includesCaseInsensitive(haystack: string | null | undefined, needle:
string | null | undefined): boolean {
+ if (haystack === null || haystack === undefined || needle === null ||
needle === undefined) return false;
+ return toLower(haystack).includes(toLower(needle));
+}
+
function getParentPath(filePathString: string | null | undefined): string {
if (!filePathString || typeof filePathString !== "string") return ".";
const lastSlash = filePathString.lastIndexOf("/");
@@ -82,11 +78,6 @@ function getParentPath(filePathString: string | null |
undefined): string {
return filePathString.substring(0, lastSlash);
}
-const toLower = (s: string | null | undefined): string => (s ||
"").toLocaleLowerCase();
-
-const includesCaseInsensitive = (haystack: string | null | undefined,
lowerNeedle: string): boolean =>
- toLower(haystack).includes(lowerNeedle);
-
function assertElementPresent<T extends HTMLElement>(element: T | null,
selector: string): T {
if (!element) {
throw new Error(`Required DOM element '${selector}' not found.`);
@@ -94,29 +85,56 @@ function assertElementPresent<T extends
HTMLElement>(element: T | null, selector
return element;
}
+function $<T extends HTMLElement = HTMLElement>(id: string): T {
+ return assertElementPresent(document.getElementById(id) as T | null, id);
+}
+
function updateMoveSelectionInfo(): void {
if (selectedFileNameTitleElement) {
- selectedFileNameTitleElement.textContent =
uiState.currentlySelectedFilePath
- ? `Select a destination for ${uiState.currentlySelectedFilePath}`
+ selectedFileNameTitleElement.textContent =
uiState.currentlySelectedFilePaths.size > 0
+ ? `Select a destination for
${uiState.currentlySelectedFilePaths.size} file(s)`
: "Select a destination for the file";
}
- let infoHTML = "";
- let disableConfirm = true;
+ const numSelectedFiles = uiState.currentlySelectedFilePaths.size;
+ const destinationDir = uiState.currentlyChosenDirectoryPath;
+ let message = "Please select files and a destination.";
+ let disableConfirmButton = true;
+
+ currentMoveSelectionInfoElement.innerHTML = '';
+
+ if (!numSelectedFiles && destinationDir) {
+
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Selected
destination: "));
+ const strongDest = document.createElement("strong");
+ strongDest.textContent = destinationDir;
+ currentMoveSelectionInfoElement.appendChild(strongDest);
+ currentMoveSelectionInfoElement.appendChild(document.createTextNode(".
Please select file(s) to move."));
+ } else if (numSelectedFiles && !destinationDir) {
+
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Moving "));
+ const strongN = document.createElement("strong");
+ strongN.textContent = `${numSelectedFiles} file(s)`;
+ currentMoveSelectionInfoElement.appendChild(strongN);
+ currentMoveSelectionInfoElement.appendChild(document.createTextNode("
to (select destination)."));
+ } else if (numSelectedFiles && destinationDir) {
+ const filesArray = Array.from(uiState.currentlySelectedFilePaths);
+ const displayFiles = filesArray.length > 1 ? `${filesArray[0]} and
${filesArray.length -1} other(s)` : filesArray[0];
+
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Move "));
+ const strongDisplayFiles = document.createElement("strong");
+ strongDisplayFiles.textContent = displayFiles;
+ currentMoveSelectionInfoElement.appendChild(strongDisplayFiles);
+ currentMoveSelectionInfoElement.appendChild(document.createTextNode("
to "));
+ const strongDest = document.createElement("strong");
+ strongDest.textContent = destinationDir;
+ currentMoveSelectionInfoElement.appendChild(strongDest);
+ message = "";
+ disableConfirmButton = false;
+ }
- if (!uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath) {
- infoHTML = `Selected destination:
<strong>${uiState.currentlyChosenDirectoryPath}</strong>. Please select a file
to move.`;
- } else if (uiState.currentlySelectedFilePath &&
!uiState.currentlyChosenDirectoryPath) {
- infoHTML = `Moving
<strong>${uiState.currentlySelectedFilePath}</strong> to (select destination).`;
- } else if (uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath) {
- infoHTML = `Move <strong>${uiState.currentlySelectedFilePath}</strong>
to <strong>${uiState.currentlyChosenDirectoryPath}</strong>`;
- disableConfirm = false;
- } else {
- infoHTML = "Please select a file and a destination.";
+ if (message && currentMoveSelectionInfoElement.childNodes.length === 0) {
+ currentMoveSelectionInfoElement.textContent = message;
}
- currentMoveSelectionInfoElement.innerHTML = infoHTML;
- confirmMoveButton.disabled = disableConfirm;
+ confirmMoveButton.disabled = disableConfirmButton;
}
function renderListItems(
@@ -128,12 +146,12 @@ function renderListItems(
const itemsToShow = items.slice(0, uiState.maxFilesToShow);
itemsToShow.forEach(item => {
- const itemPathString = config.itemType === ItemType.Dir && !item ? "."
: String(item || "");
+ const itemPathString = (config.itemType === ItemType.Dir && !item) ?
"." : String(item || "");
const row = document.createElement("tr");
row.className = "page-table-row-interactive";
- const buttonCell = row.insertCell();
- buttonCell.className = "page-table-button-cell text-end";
+ const controlCell = row.insertCell();
+ controlCell.className = "page-table-button-cell text-end";
const pathCell = row.insertCell();
pathCell.className = "page-table-path-cell";
@@ -141,40 +159,42 @@ function renderListItems(
span.className = "page-file-select-text";
span.textContent = itemPathString;
- let isIncompatible = false;
- if (config.itemType === ItemType.File) {
- if (uiState.currentlyChosenDirectoryPath &&
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
- isIncompatible = true;
+ switch (config.itemType) {
+ case ItemType.File: {
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.className = "form-check-input ms-2";
+ checkbox.dataset.filePath = itemPathString;
+ checkbox.checked =
uiState.currentlySelectedFilePaths.has(itemPathString);
+ checkbox.setAttribute("aria-label", `Select file
${itemPathString}`);
+ if (uiState.currentlyChosenDirectoryPath &&
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
+ checkbox.disabled = true;
+ span.classList.add("page-extra-muted");
+ }
+ controlCell.appendChild(checkbox);
+ break;
}
- } else {
- if (uiState.currentlySelectedFilePath &&
getParentPath(uiState.currentlySelectedFilePath) === itemPathString) {
- isIncompatible = true;
+ case ItemType.Dir: {
+ const radio = document.createElement("input");
+ radio.type = "radio";
+ radio.name = "target-directory-radio";
+ radio.className = "form-check-input ms-2";
+ radio.value = itemPathString;
+ radio.dataset.dirPath = itemPathString;
+ radio.checked = itemPathString === config.selectedItem;
+ radio.setAttribute("aria-label", `Choose directory
${itemPathString}`);
+
+ if (itemPathString === config.selectedItem) {
+ row.classList.add("page-item-selected");
+ row.setAttribute("aria-selected", "true");
+ span.classList.add("fw-bold");
+ } else {
+ row.setAttribute("aria-selected", "false");
+ }
+ controlCell.appendChild(radio);
+ break;
}
}
- if (isIncompatible) {
- span.classList.add("page-extra-muted");
- }
-
- if (itemPathString === config.selectedItem) {
- row.classList.add("page-item-selected");
- row.setAttribute("aria-selected", "true");
- span.classList.add("fw-bold");
-
- const arrowSpan = document.createElement("span");
- arrowSpan.className = "text-success fs-1";
- arrowSpan.textContent = "→";
- buttonCell.appendChild(arrowSpan);
- } else {
- row.setAttribute("aria-selected", "false");
-
- const button = document.createElement("button") as
HTMLButtonElement & { dataset: ButtonDataset };
- button.type = "button";
- button.className = `btn btn-sm ${config.buttonClassBase}
${config.buttonClassOutline}`;
- button.dataset[config.itemType === ItemType.File ? "filePath" :
"dirPath"] = itemPathString;
- button.textContent = config.buttonTextDefault;
-
- buttonCell.appendChild(button);
- }
pathCell.appendChild(span);
fragment.appendChild(row);
@@ -182,10 +202,10 @@ function renderListItems(
tbodyElement.replaceChildren(fragment);
- const moreInfoElement = document.getElementById(config.moreInfoId) as
HTMLElement | null;
+ const moreInfoElement = document.getElementById(config.moreInfoId);
if (moreInfoElement) {
if (items.length > uiState.maxFilesToShow) {
- moreInfoElement.textContent = `${items.length -
uiState.maxFilesToShow} more available (filter to browse)...`;
+ moreInfoElement.textContent = `${items.length -
uiState.maxFilesToShow} ${TXT.MoreItemsHint}`;
moreInfoElement.style.display = "block";
} else {
moreInfoElement.textContent = "";
@@ -195,196 +215,286 @@ function renderListItems(
}
function renderAllLists(): void {
- const lowerFileFilter = toLower(uiState.fileFilter);
const filteredFilePaths = uiState.originalFilePaths.filter(fp =>
- includesCaseInsensitive(fp, lowerFileFilter)
+ includesCaseInsensitive(fp, uiState.filters.file)
);
const filesConfig: RenderListDisplayConfig = {
itemType: ItemType.File,
- selectedItem: uiState.currentlySelectedFilePath,
- buttonClassBase: "select-file-btn",
- buttonClassOutline: "btn-outline-primary",
- buttonClassActive: "btn-primary",
- buttonTextSelected: TXT_SELECTED,
- buttonTextDefault: TXT_SELECT,
- moreInfoId: FILE_LIST_MORE_INFO_ID
+ selectedItem: null,
+ moreInfoId: ID.fileListMoreInfo
};
renderListItems(fileListTableBody, filteredFilePaths, filesConfig);
- const lowerDirFilter = toLower(uiState.dirFilter);
const filteredDirs = uiState.allTargetDirs.filter(dirP =>
- includesCaseInsensitive(dirP, lowerDirFilter)
+ includesCaseInsensitive(dirP, uiState.filters.dir)
);
const dirsConfig: RenderListDisplayConfig = {
itemType: ItemType.Dir,
selectedItem: uiState.currentlyChosenDirectoryPath,
- buttonClassBase: "choose-dir-btn",
- buttonClassOutline: "btn-outline-secondary",
- buttonClassActive: "btn-secondary",
- buttonTextSelected: TXT_CHOSEN,
- buttonTextDefault: TXT_CHOOSE,
- moreInfoId: DIR_LIST_MORE_INFO_ID
+ moreInfoId: ID.dirListMoreInfo
};
renderListItems(dirListTableBody, filteredDirs, dirsConfig);
updateMoveSelectionInfo();
}
-function handleFileSelection(filePath: string | null): void {
- if (uiState.currentlyChosenDirectoryPath) {
- const parentOfNewFile = getParentPath(filePath);
- if (parentOfNewFile === uiState.currentlyChosenDirectoryPath) {
- uiState.currentlyChosenDirectoryPath = null;
- }
- }
- uiState.currentlySelectedFilePath = filePath;
- renderAllLists();
-}
-
function handleDirSelection(dirPath: string | null): void {
- if (dirPath && uiState.currentlySelectedFilePath &&
getParentPath(uiState.currentlySelectedFilePath) === dirPath)
uiState.currentlySelectedFilePath = null;
uiState.currentlyChosenDirectoryPath = dirPath;
renderAllLists();
}
-function onFileListClick(event: Event): void {
- const targetElement = event.target as HTMLElement;
- const button =
targetElement.closest<HTMLButtonElement>("button.select-file-btn");
- if (button && !button.disabled) {
- const filePath = button.dataset.filePath || null;
- handleFileSelection(filePath);
+function delegate<T extends HTMLElement>(
+ parent: HTMLElement,
+ selector: string,
+ handler: (el: T, event: Event) => void,
+): void {
+ parent.addEventListener("click", (e: Event) => {
+ const targetElement = e.target as Element | null;
+ if (targetElement) {
+ const el = targetElement.closest(selector);
+ if (el instanceof HTMLElement) {
+ handler(el as T, e);
+ }
}
+ });
}
-function onDirListClick(event: Event): void {
- const targetElement = event.target as HTMLElement;
- const button =
targetElement.closest<HTMLButtonElement>("button.choose-dir-btn");
- if (button && !button.disabled) {
- const dirPath = button.dataset.dirPath || null;
+function handleFileCheckbox(checkbox: HTMLInputElement): void {
+ const filePath = checkbox.dataset.filePath;
+ if (filePath) {
+ if (checkbox.checked) {
+ uiState.currentlySelectedFilePaths.add(filePath);
+ } else {
+ uiState.currentlySelectedFilePaths.delete(filePath);
+ }
+ renderAllLists();
+ }
+}
+
+function handleDirRadio(radio: HTMLInputElement): void {
+ if (radio.checked) {
+ const dirPath = radio.dataset.dirPath || null;
handleDirSelection(dirPath);
}
}
-function onFileFilterInput(event: FilterInputEvent): void {
- uiState.fileFilter = event.target.value;
- renderAllLists();
+function setState(partial: Partial<UIState>): void {
+ uiState = {...uiState, ...partial};
+ renderAllLists();
}
-function onDirFilterInput(event: FilterInputEvent): void {
- uiState.dirFilter = event.target.value;
- renderAllLists();
+function onFileFilterInput(event: Event): void {
+ const target = event.target;
+ if (target instanceof HTMLInputElement) {
+ setState({filters: { ...uiState.filters, file: target.value }});
+ }
}
-function onMaxFilesChange(event: FilterInputEvent): void {
- const newValue = parseInt(event.target.value, 10);
- if (newValue >= 1) {
- uiState.maxFilesToShow = newValue;
- renderAllLists();
- } else {
- event.target.value = String(uiState.maxFilesToShow);
+function onDirFilterInput(event: Event): void {
+ const target = event.target;
+ if (target instanceof HTMLInputElement) {
+ setState({filters: { ...uiState.filters, dir: target.value }});
}
}
-async function onConfirmMoveClick(): Promise<void> {
- errorAlert.classList.add("d-none");
- if (uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
- const formData = new FormData();
- formData.append("csrf_token", uiState.csrfToken);
- formData.append("source_file", uiState.currentlySelectedFilePath);
- formData.append("target_directory",
uiState.currentlyChosenDirectoryPath);
-
- try {
- const response = await fetch(window.location.pathname, {
- method: "POST",
- body: formData,
- credentials: "same-origin",
- headers: {
- "Accept": "application/json",
- },
- });
-
- if (response.ok) {
- window.location.reload();
- } else {
- let errorMsg = `An error occurred while moving the file
(Status: ${response.status})`;
- if (response.status === 403) errorMsg = "Permission denied to
move the file.";
- if (response.status === 400) errorMsg = "Invalid request to
move the file.";
- try {
- const errorData = await response.json();
- if (errorData && typeof errorData.error === "string")
errorMsg = errorData.error;
- } catch (_) { }
- errorAlert.textContent = errorMsg;
- errorAlert.classList.remove("d-none");
- return;
- }
- } catch (error) {
- console.error("Network or fetch error:", error);
- errorAlert.textContent = "A network error occurred. Please check
your connection and try again.";
- errorAlert.classList.remove("d-none");
- }
+function onMaxFilesChange(event: Event): void {
+ const target = event.target as HTMLInputElement;
+ const newValue = parseInt(target.value, 10);
+ if (newValue >= 1) {
+ setState({maxFilesToShow: newValue});
} else {
- errorAlert.textContent = "Please select both a file to move and a
destination directory.";
- errorAlert.classList.remove("d-none");
+ target.value = String(uiState.maxFilesToShow);
}
}
-document.addEventListener("DOMContentLoaded", function () {
- fileFilterInput =
assertElementPresent(document.querySelector<HTMLInputElement>(`#${FILE_FILTER_ID}`),
FILE_FILTER_ID);
- fileListTableBody =
assertElementPresent(document.querySelector<HTMLTableSectionElement>(`#${FILE_LIST_TABLE_BODY_ID}`),
FILE_LIST_TABLE_BODY_ID);
- maxFilesInput =
assertElementPresent(document.querySelector<HTMLInputElement>(`#${MAX_FILES_INPUT_ID}`),
MAX_FILES_INPUT_ID);
- selectedFileNameTitleElement =
assertElementPresent(document.getElementById(SELECTED_FILE_NAME_TITLE_ID) as
HTMLElement, SELECTED_FILE_NAME_TITLE_ID);
- dirFilterInput =
assertElementPresent(document.querySelector<HTMLInputElement>(`#${DIR_FILTER_INPUT_ID}`),
DIR_FILTER_INPUT_ID);
- dirListTableBody =
assertElementPresent(document.querySelector<HTMLTableSectionElement>(`#${DIR_LIST_TABLE_BODY_ID}`),
DIR_LIST_TABLE_BODY_ID);
- confirmMoveButton =
assertElementPresent(document.querySelector<HTMLButtonElement>(`#${CONFIRM_MOVE_BUTTON_ID}`),
CONFIRM_MOVE_BUTTON_ID);
- currentMoveSelectionInfoElement =
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID) as
HTMLElement, CURRENT_MOVE_SELECTION_INFO_ID);
- currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
- errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID)
as HTMLElement, ERROR_ALERT_ID);
-
- let initialFilePaths: string[] = [];
- let initialTargetDirs: string[] = [];
+type Ok = { ok: true };
+type Err = { ok: false; message: string };
+
+type MoveResult = Ok | Err;
+
+interface ErrorResponse {
+ message?: string;
+ error?: string;
+}
+
+function isErrorResponse(data: unknown): data is ErrorResponse {
+ return typeof data === 'object' && data !== null &&
+ (('message' in data && typeof (data as {message: unknown}).message
=== 'string') ||
+ ('error' in data && typeof (data as {error: unknown}).error ===
'string'));
+}
+
+async function moveFiles(files: readonly string[], dest: string, csrfToken:
string, signal?: AbortSignal): Promise<MoveResult> {
+ const formData = new FormData();
+ formData.append("csrf_token", csrfToken);
+ for (const file of files) {
+ formData.append("source_files", file);
+ }
+ formData.append("target_directory", dest);
+
try {
- const fileDataElement = document.getElementById(FILE_DATA_ID);
- if (fileDataElement?.textContent) {
- initialFilePaths = JSON.parse(fileDataElement.textContent);
+ const response = await fetch(window.location.pathname, {
+ method: "POST",
+ body: formData,
+ credentials: "same-origin",
+ headers: {
+ "Accept": "application/json",
+ },
+ signal,
+ });
+
+ if (response.ok) {
+ return { ok: true };
+ } else {
+ let errorMsg = `An error occurred while moving the file (Status:
${response.status})`;
+ if (response.status === 403) errorMsg = "Permission denied to move
the file.";
+ if (response.status === 400) errorMsg = "Invalid request to move
the file.";
+ if (signal?.aborted) {
+ errorMsg = "Move operation aborted.";
+ }
+ try {
+ const errorData: unknown = await response.json();
+ if (isErrorResponse(errorData)) {
+ if (errorData.message) {
+ errorMsg = errorData.message;
+ } else if (errorData.error) {
+ errorMsg = errorData.error;
+ }
+ }
+ } catch { /* Do nothing */ }
+ return { ok: false, message: errorMsg };
}
- const dirDataElement = document.getElementById(DIR_DATA_ID);
- if (dirDataElement?.textContent) {
- initialTargetDirs = JSON.parse(dirDataElement.textContent);
+ } catch (error: unknown) {
+ // console.error("Network or fetch error:", error);
+ if (error instanceof Error && error.name === 'AbortError') {
+ return { ok: false, message: "Move operation aborted." };
}
- } catch (e) {
- console.error("Error parsing JSON data:", e);
+ return { ok: false, message: "A network error occurred. Please check
your connection and try again." };
}
+}
- if (initialFilePaths.length === 0 && initialTargetDirs.length === 0) {
- alert("Warning: File and/or directory lists could not be loaded or are
empty.");
+function splitMoveCandidates(
+ selected: Iterable<string>,
+ dest: string,
+): { readonly toMove: readonly string[]; readonly alreadyThere: readonly
string[] } {
+ const toMoveMutable: string[] = [];
+ const alreadyThereMutable: string[] = [];
+ for (const filePath of selected) {
+ if (getParentPath(filePath) === dest) {
+ alreadyThereMutable.push(filePath);
+ } else {
+ toMoveMutable.push(filePath);
+ }
}
+ return { toMove: toMoveMutable, alreadyThere: alreadyThereMutable };
+}
- const mainScriptDataElement = document.querySelector<HTMLElement & {
dataset: { csrfToken?: string } }>(`#${MAIN_SCRIPT_DATA_ID}`);
- const initialCsrfToken = mainScriptDataElement?.dataset.csrfToken || null;
-
- uiState = {
- fileFilter: fileFilterInput.value || "",
- dirFilter: dirFilterInput.value || "",
- maxFilesToShow: parseInt(maxFilesInput.value, 10) ||
MAX_FILES_FALLBACK,
- currentlySelectedFilePath: null,
- currentlyChosenDirectoryPath: null,
- originalFilePaths: initialFilePaths,
- allTargetDirs: initialTargetDirs,
- csrfToken: initialCsrfToken,
- };
- if (isNaN(uiState.maxFilesToShow) || uiState.maxFilesToShow < 1) {
- uiState.maxFilesToShow = MAX_FILES_FALLBACK;
- maxFilesInput.value = String(uiState.maxFilesToShow);
- }
+async function onConfirmMoveClick(): Promise<void> {
+ errorAlert.classList.add("d-none");
+ errorAlert.textContent = "";
- fileFilterInput.addEventListener("input", onFileFilterInput as
EventListener);
- dirFilterInput.addEventListener("input", onDirFilterInput as
EventListener);
- maxFilesInput.addEventListener("change", onMaxFilesChange as
EventListener);
+ const controller = new AbortController();
+ window.addEventListener("beforeunload", () => controller.abort());
- fileListTableBody.addEventListener("click", onFileListClick);
- dirListTableBody.addEventListener("click", onDirListClick);
+ if (uiState.currentlySelectedFilePaths.size > 0 &&
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
+ const { toMove, alreadyThere: filesAlreadyInDest } =
splitMoveCandidates(
+ uiState.currentlySelectedFilePaths,
+ uiState.currentlyChosenDirectoryPath
+ );
- confirmMoveButton.addEventListener("click", onConfirmMoveClick);
+ if (toMove.length === 0 && filesAlreadyInDest.length > 0 &&
uiState.currentlySelectedFilePaths.size > 0) {
+ errorAlert.classList.remove("d-none");
+ errorAlert.textContent = `All selected files
(${filesAlreadyInDest.join(", ")}) are already in the target directory. No
files were moved.`;
+ confirmMoveButton.disabled = false;
+ return;
+ }
- renderAllLists();
+ if (filesAlreadyInDest.length > 0) {
+ const alreadyInDestMsg = `Note: ${filesAlreadyInDest.join(", ")}
${filesAlreadyInDest.length === 1 ? "is" : "are"} already in the target
directory and will not be moved.`;
+ const existingError = errorAlert.textContent;
+ errorAlert.textContent = existingError ? `${existingError}
${alreadyInDestMsg}` : alreadyInDestMsg;
+ }
+
+ const result = await moveFiles(toMove,
uiState.currentlyChosenDirectoryPath, uiState.csrfToken, controller.signal);
+
+ if (result.ok) {
+ window.location.reload();
+ } else {
+ errorAlert.classList.remove("d-none");
+ errorAlert.textContent = result.message;
+ }
+ } else {
+ errorAlert.classList.remove("d-none");
+ errorAlert.textContent = "Please select file(s) and a destination
directory.";
+ }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ fileFilterInput = $<HTMLInputElement>(ID.fileFilter);
+ fileListTableBody = $<HTMLTableSectionElement>(ID.fileListTableBody);
+ maxFilesInput = $<HTMLInputElement>(ID.maxFilesInput);
+ selectedFileNameTitleElement = $(ID.selectedFileNameTitle);
+ dirFilterInput = $<HTMLInputElement>(ID.dirFilterInput);
+ dirListTableBody = $<HTMLTableSectionElement>(ID.dirListTableBody);
+ confirmMoveButton = $<HTMLButtonElement>(ID.confirmMoveButton);
+ currentMoveSelectionInfoElement = $(ID.currentMoveSelectionInfo);
+ currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
+ errorAlert = $(ID.errorAlert);
+
+ let initialFilePaths: string[] = [];
+ let initialTargetDirs: string[] = [];
+ try {
+ const fileData = document.getElementById(ID.fileData)?.textContent;
+ if (fileData) initialFilePaths = JSON.parse(fileData) as string[];
+ const dirData = document.getElementById(ID.dirData)?.textContent;
+ if (dirData) initialTargetDirs = JSON.parse(dirData) as string[];
+ } catch {
+ // console.error("Error parsing JSON data:");
+ }
+
+ const csrfToken =
+ document
+ .querySelector<HTMLElement & { dataset: { csrfToken?: string } }>(
+ `#${ID.mainScriptData}`,
+ )?.dataset.csrfToken ?? null;
+
+ uiState = {
+ filters: {
+ file: fileFilterInput.value || "",
+ dir: dirFilterInput.value || "",
+ },
+ maxFilesToShow:
+ Math.max(parseInt(maxFilesInput.value, 10) || 0, 1) ||
MAX_FILES_FALLBACK,
+ currentlySelectedFilePaths: new Set(),
+ currentlyChosenDirectoryPath: null,
+ originalFilePaths: initialFilePaths,
+ allTargetDirs: initialTargetDirs,
+ csrfToken,
+ };
+ maxFilesInput.value = String(uiState.maxFilesToShow);
+
+ fileFilterInput.addEventListener("input", onFileFilterInput as
EventListener);
+ dirFilterInput.addEventListener("input", onDirFilterInput as EventListener);
+ maxFilesInput.addEventListener("change", onMaxFilesChange as EventListener);
+
+ delegate<HTMLInputElement>(
+ fileListTableBody,
+ "input[type='checkbox'][data-file-path]",
+ handleFileCheckbox,
+ );
+ delegate<HTMLInputElement>(
+ dirListTableBody,
+ "input[type='radio'][name='target-directory-radio']",
+ handleDirRadio,
+ );
+ confirmMoveButton.addEventListener("click", () => {
+ onConfirmMoveClick().catch(_err => {
+ // console.error("Error in onConfirmMoveClick handler:", _err);
+ if (errorAlert) {
+ errorAlert.classList.remove("d-none");
+ errorAlert.textContent = "An unexpected error occurred. Please try
again.";
+ }
+ });
+ });
+
+ renderAllLists();
});
diff --git a/atr/templates/finish-selected.html
b/atr/templates/finish-selected.html
index e1c4e58..c330b29 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -108,7 +108,7 @@
</div>
{% if can_move %}
- <h2>Move a file to a different directory</h2>
+ <h2>Move files to a different directory</h2>
<p>
Note that files with associated metadata (e.g. <code>.asc</code> or
<code>.sha512</code> files) are treated as a single unit and will be moved
together if any one of them is selected for movement.
</p>
@@ -121,7 +121,7 @@
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header bg-light">
- <h3 class="mb-0">Select a file to move</h3>
+ <h3 class="mb-0">Select files to move</h3>
</div>
<div class="card-body">
<input type="text"
@@ -168,7 +168,7 @@
min="1" />
</div>
<div id="current-move-selection-info" class="text-muted">Please select
a file and a destination.</div>
- <button type="button" id="confirm-move-button" class="btn btn-success
mt-2">Move file to selected directory</button>
+ <button type="button" id="confirm-move-button" class="btn btn-success
mt-2">Move to selected directory</button>
</div>
</form>
{% else %}
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..a72c229
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,37 @@
+// eslint.config.mjs
+import eslint from "@eslint/js";
+import tseslint from "typescript-eslint";
+import globals from "globals";
+
+export default tseslint.config(
+ {
+ ignores: [
+ "atr/static/js/**/*.js",
+ "node_modules/",
+ "dist/",
+ "build/",
+ "coverage/",
+ ".venv/",
+ "eslint.config.mjs"
+ ]
+ },
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+ ...tseslint.configs.recommendedTypeChecked,
+ {
+ languageOptions: {
+ parserOptions: { project: true, tsconfigRootDir: import.meta.dirname },
+ globals: { ...globals.browser }
+ },
+ rules: {
+ "no-console": "warn",
+ "no-debugger": "warn",
+ "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_"
}],
+ "@typescript-eslint/no-explicit-any": "warn",
+ "@typescript-eslint/explicit-function-return-type": "error",
+ "@typescript-eslint/explicit-module-boundary-types": "error",
+ "@typescript-eslint/no-floating-promises": "error",
+ "@typescript-eslint/consistent-type-imports": "error"
+ }
+ }
+);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8db2c6d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "scripts": {
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
+ },
+ "devDependencies": {
+ "eslint": "^9.0.0",
+ "globals": "^15.0.0",
+ "typescript": "^5.0.0",
+ "typescript-eslint": "latest"
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index bfec183..cf1a6bf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -98,6 +98,9 @@ executionEnvironments = [
[tool.ruff]
line-length = 120
+extend-exclude = [
+ "node_modules",
+]
[tool.ruff.lint]
ignore = []
@@ -123,7 +126,7 @@ select = [
[tool.mypy]
python_version = "3.13"
-exclude = ["tests"]
+exclude = ["node_modules", "tests"]
plugins = ["pydantic.mypy"]
mypy_path = "typestubs"
check_untyped_defs = false
diff --git a/tsconfig.json b/tsconfig.json
index 29c000c..a1c45e2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,20 +1,15 @@
{
"compilerOptions": {
- "target": "es6",
+ "target": "ES6",
"module": "commonjs",
- "outDir": "./atr/static/js",
"rootDir": "./atr/static/ts",
+ "outDir": "./atr/static/js",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
"sourceMap": true
},
- "include": [
- "./atr/static/ts/**/*.ts"
- ],
- "exclude": [
- "./atr/static/js"
- ]
+ "include": ["./atr/static/ts/**/*.ts"],
+ "exclude": ["./atr/static/js", "node_modules"]
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]