diff --git a/locale/vi/LC_MESSAGES/django.po b/locale/vi/LC_MESSAGES/django.po
index 4891e7e2c..6156903a3 100644
--- a/locale/vi/LC_MESSAGES/django.po
+++ b/locale/vi/LC_MESSAGES/django.po
@@ -6251,6 +6251,14 @@ msgstr "Thêm test mới"
msgid "Save"
msgstr "Lưu"
+#: templates/problem/data.html:265
+msgid "Data file (file, folder, or zip archive)"
+msgstr "Tập tin dữ liệu (file, folder hoặc tệp zip)"
+
+#: templates/problem/data.html:266 templates/problem/data.html:267
+msgid "Hold Shift while clicking to select a folder"
+msgstr "Giữ Shift khi bấm để chọn thư mục"
+
#: templates/problem/editor.html:93
msgid "Edit problem in admin panel for more options"
msgstr "Sửa bài tập này ở admin panel để có nhiều tùy chỉnh hơn"
diff --git a/templates/problem/data.html b/templates/problem/data.html
index 5c3d0cf3a..deacc9b04 100644
--- a/templates/problem/data.html
+++ b/templates/problem/data.html
@@ -251,15 +251,261 @@
href: "/custom_checkers"
}).appendTo($checker.parent());
- var $file_test = $('#id_problem-data-zipfile');
- $("
").appendTo($file_test.parent());
+ const $zipInput = $('#id_problem-data-zipfile');
+ $("
").appendTo($zipInput.parent());
$("", {
type: "submit",
value: {{ _('Please press this button if you have just updated the zip data')|htmltojs }},
class: "button",
style: "display: inherit",
id: "submit-button",
- }).appendTo($file_test.parent());
+ }).appendTo($zipInput.parent());
+
+ const $zipLabel = $zipInput.closest('tr').find('label[for="id_problem-data-zipfile"]');
+ $zipLabel.text({{_('Data file (file, folder, or zip archive)')|htmltojs}})
+ .attr('title', {{_('Hold Shift while clicking to select a folder')|htmltojs}});
+ $zipInput.attr('title', {{_('Hold Shift while clicking to select a folder')|htmltojs}});
+
+
+ const allowedLooseExtensions = /\.(?:in|inp|out|ok|ans|a|txt)$/i;
+ const zipMimeType = 'application/zip';
+ let skipSyntheticZipChange = false;
+
+ function enableFileSelectionMode() {
+ $zipInput.attr('multiple', true)
+ .attr('accept', '.zip,.in,.inp,.out,.ok,.ans,.a,.txt')
+ .removeAttr('webkitdirectory')
+ .removeAttr('directory');
+ }
+
+ function enableFolderSelectionMode() {
+ $zipInput.removeAttr('multiple')
+ .attr('webkitdirectory', true)
+ .attr('directory', true)
+ .removeAttr('accept');
+ }
+
+ enableFileSelectionMode();
+
+ $zipInput.on('mousedown', function (event) {
+ if (event.shiftKey) {
+ enableFolderSelectionMode();
+ } else {
+ enableFileSelectionMode();
+ }
+ });
+
+ function normalizeRelativePath(file) {
+ if (file.webkitRelativePath && file.webkitRelativePath.length) {
+ return file.webkitRelativePath;
+ }
+ return file.name;
+ }
+
+ function shouldIgnoreSystemPath(path) {
+ if (!path) {
+ return true;
+ }
+ const normalized = path.replace(/^\.\/?/, '');
+ if (!normalized.length) {
+ return true;
+ }
+ if (normalized.toUpperCase().startsWith('__MACOSX/')) {
+ return true;
+ }
+ const segments = normalized.split('/');
+ const lastSegment = segments[segments.length - 1];
+ if (!lastSegment) {
+ return true;
+ }
+ const lowerSegment = lastSegment.toLowerCase();
+ return lowerSegment === '.ds_store' || lowerSegment.startsWith('._');
+ }
+
+ function isAllowedLooseFile(path) {
+ const segments = path.split('/');
+ const filename = segments[segments.length - 1];
+ if (!filename) {
+ return false;
+ }
+ if (allowedLooseExtensions.test(filename)) {
+ return true;
+ }
+ return testcaseInputPatterns.some(function (pattern) {
+ return pattern.test(filename);
+ }) || testcaseOutputPatterns.some(function (pattern) {
+ return pattern.test(filename);
+ });
+ }
+
+ function resetFileInput(input) {
+ if (input) {
+ input.value = '';
+ }
+ }
+
+ function guessZipName(files) {
+ if (!files.length) {
+ return 'testcases.zip';
+ }
+ const first = files[0];
+ const rel = normalizeRelativePath(first);
+ const firstSegment = rel.split('/').filter(Boolean)[0];
+ if (firstSegment) {
+ return `${firstSegment.replace(/\s+/g, '_')}.zip`;
+ }
+ const baseName = first.name.replace(/\.[^.]*$/, '') || 'testcases';
+ return `${baseName}.zip`;
+ }
+
+ function isZipFile(file) {
+ if (!file) {
+ return false;
+ }
+ if (file.type && file.type.toLowerCase() === 'application/zip') {
+ return true;
+ }
+ return /\.zip$/i.test(file.name || '');
+ }
+
+ function attachZipFile(zipFile) {
+ if (!zipFile) {
+ return;
+ }
+ if (typeof DataTransfer === 'undefined') {
+ alert({{ _('Automatic zipping is not supported in this browser. Please upload a ZIP file manually.')|htmltojs }});
+ resetFileInput($zipInput[0]);
+ enableFileSelectionMode();
+ return;
+ }
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(zipFile);
+ skipSyntheticZipChange = true;
+ $zipInput[0].files = dataTransfer.files;
+ $zipInput.trigger('change');
+ }
+
+ function processZipFile(file) {
+ if (!file) {
+ return;
+ }
+ window.valid_files = [];
+ const reader = new FileReader();
+ reader.onload = function (ev) {
+ JSZip.loadAsync(ev.target.result).then(function (zip) {
+ const allFiles = Object.keys(zip.files).sort();
+ window.valid_files = allFiles.filter(function (path) {
+ const entry = zip.files[path];
+ if (entry && entry.dir) {
+ return false;
+ }
+ return !shouldIgnoreSystemPath(path);
+ });
+ fill_testcases();
+ }).catch(function (err) {
+ console.error(err);
+ alert({{ _('Test file must be a ZIP file')|htmltojs }});
+ resetFileInput($zipInput[0]);
+ enableFileSelectionMode();
+ });
+ };
+ reader.onerror = function () {
+ alert({{ _('Could not read the selected file')|htmltojs }});
+ resetFileInput($zipInput[0]);
+ enableFileSelectionMode();
+ };
+ reader.readAsArrayBuffer(file);
+ }
+
+ function createZipFromSelection(files) {
+ if (!files.length) {
+ resetFileInput($zipInput[0]);
+ enableFileSelectionMode();
+ return;
+ }
+
+ if (files.length === 1 && isZipFile(files[0])) {
+ attachZipFile(files[0]);
+ return;
+ }
+
+ const zipBuilder = new JSZip();
+ const skipped = [];
+ let added = 0;
+
+ files.forEach(function (file) {
+ const relativePath = normalizeRelativePath(file);
+ if (shouldIgnoreSystemPath(relativePath)) {
+ return;
+ }
+ if (!isAllowedLooseFile(relativePath)) {
+ skipped.push(relativePath);
+ return;
+ }
+ zipBuilder.file(relativePath, file);
+ added += 1;
+ });
+
+ if (!added) {
+ alert({{ _('No valid testcase files found. Please use standard testcase names such as *.in, *.inp, *.out, *.ok, *.ans, *.a or input.1')|htmltojs }});
+ resetFileInput($zipInput[0]);
+ enableFileSelectionMode();
+ return;
+ }
+
+ zipBuilder.generateAsync({ type: 'blob' }).then(function (blob) {
+ const fileName = guessZipName(files).replace(/\.zip$/i, '') + '.zip';
+ const zipFile = new File([blob], fileName, { type: zipMimeType });
+ attachZipFile(zipFile);
+ if (skipped.length) {
+ const skippedList = skipped.join('\n');
+ alert({{ _('Some files were ignored because their names do not match allowed testcase patterns:')|htmltojs }} + '\n' + skippedList);
+ }
+ }).catch(function (err) {
+ console.error(err);
+ alert({{ _('Failed to create ZIP file from selected files')|htmltojs }});
+ resetFileInput($zipInput[0]);
+ }).finally(function () {
+ enableFileSelectionMode();
+ });
+ }
+
+ $zipInput.on('change', function (event) {
+ const files = Array.from(event.target.files || []);
+ if (!files.length) {
+ window.valid_files = [];
+ enableFileSelectionMode();
+ return;
+ }
+ if (skipSyntheticZipChange) {
+ skipSyntheticZipChange = false;
+ enableFileSelectionMode();
+ processZipFile(files[0]);
+ return;
+ }
+
+ if (files.length === 1 && isZipFile(files[0])) {
+ enableFileSelectionMode();
+ processZipFile(files[0]);
+ return;
+ }
+
+ createZipFromSelection(files);
+ });
+
+ const testcaseInputPatterns = [
+ new RegExp(/^(.+\.inp|.+\.in|inp|in)$/i),
+ new RegExp(/^input.(.+\d+)$/i),
+ new RegExp(/^(.+\d+)$/),
+ new RegExp(/^(?=.*?\.in|in).*?(?:(?:^|\W)(?\d+)[^\d\s]+)?(?\d+)[^\d\s]*$/i),
+ ];
+
+ const testcaseOutputPatterns = [
+ new RegExp(/^(.+\.out|.+\.ok|.+\.ans|out|ok|ans)$/i),
+ new RegExp(/^output.(.+\d+)$/i),
+ new RegExp(/^(.+\d+\.a)$/i),
+ new RegExp(/^(?=.*?\.out|out).*?(?:(?:^|\W)(?\d+)[^\d\s]+)?(?\d+)[^\d\s]*$/i),
+ ];
function swap_row($a, $b) {
var $a_order = $a.find('input[id$=order]'), $b_order = $b.find('input[id$=order]');
@@ -439,20 +685,8 @@
var inFiles = [], outFiles = [];
var format = ["Themis", "CMS", "Polygon", "DMOJ"];
-
- var in_re = [
- new RegExp(/^(.+\.inp|.+\.in|inp|in)$/),
- new RegExp(/^input.(.+\d+)$/),
- new RegExp(/^(.+\d+)$/),
- new RegExp(/^(?=.*?\.in|in).*?(?:(?:^|\W)(?\d+)[^\d\s]+)?(?\d+)[^\d\s]*$/),
- ];
-
- var out_re = [
- new RegExp(/^(.+\.out|.+\.ok|.+\.ans|out|ok|ans)$/),
- new RegExp(/^output.(.+\d+)$/),
- new RegExp(/^(.+\d+\.a)$/),
- new RegExp(/^(?=.*?\.out|out).*?(?:(?:^|\W)(?\d+)[^\d\s]+)?(?\d+)[^\d\s]*$/),
- ];
+ var in_re = testcaseInputPatterns;
+ var out_re = testcaseOutputPatterns;
var test_type = -1;
@@ -561,27 +795,8 @@
fill_testcases();
}
- $("#id_problem-data-zipfile").change((event) => {
- let fileInput = event.target.files[0];
- var reader = new FileReader();
- reader.onload = function(ev) {
- JSZip.loadAsync(ev.target.result).then(function(zip) {
- let all_files = Object.keys(zip.files).sort();
- // ignore macos stupid files
- window.valid_files = all_files.filter(file => !file.startsWith('__MACOSX/') && !file.startsWith('._') && !file.startsWith('.DS_Store'));
-
- fill_testcases();
- }).catch(function(err) {
- console.log(err);
- console.error("Failed to open as ZIP file");
- alert({{ _('Test file must be a ZIP file')|htmltojs }});
- event.target.value = "";
- })
- };
- reader.readAsArrayBuffer(fileInput);
- })
$('form').dirty('setAsClean');
- }).change();
+ });
{% endblock %}