diff --git a/openreview/arr/process/camera_ready_preprocess.js b/openreview/arr/process/camera_ready_preprocess.js new file mode 100644 index 000000000..c547e6f4b --- /dev/null +++ b/openreview/arr/process/camera_ready_preprocess.js @@ -0,0 +1,8 @@ +async function process(client, edit, invitation) { + client.throwErrors = true; + const { invitations } = await client.getInvitations({ id: invitation.invitations[0] }); + const metaInvitation = invitations[0]; + const script = metaInvitation.content.revision_preprocess_script.value; + eval(`var process = ${script}`); + await process(client, edit, invitation); +} diff --git a/openreview/arr/process/camera_ready_preprocess_content.js b/openreview/arr/process/camera_ready_preprocess_content.js new file mode 100644 index 000000000..266273fa5 --- /dev/null +++ b/openreview/arr/process/camera_ready_preprocess_content.js @@ -0,0 +1,67 @@ +async function process(client, edit, invitation) { + client.throwErrors = true; + + const texRules = [ + { name: 'TeX environment', regex: /\\begin\{[^}\n]+\}[\s\S]*?\\end\{[^}\n]+\}/ }, + { name: 'TeX command', regex: /\\[A-Za-z]+(?:\*)?\b/ }, + { name: 'TeX escaped symbol', regex: /\\[%&#_$^{}]/ }, + { name: 'TeX display math', regex: /(^|[^\\])\$\$[\s\S]+?\$\$/, trimPrefixCapture: true }, + { name: 'TeX inline math', regex: /(^|[^\\])\$(?:[^$\n]|\\\$)+\$/, trimPrefixCapture: true }, + { name: 'TeX math \\(...\\)', regex: /\\\([\s\S]+?\\\)/ }, + { name: 'TeX math \\[...\\]', regex: /\\\[[\s\S]+?\\\]/ } + ]; + + const markdownRules = [ + { name: 'Markdown link', regex: /\[[^\]\n]+\]\([^)]+\)/ }, + { name: 'Markdown autolink', regex: /\s]+>/i }, + { name: 'Markdown fenced code', regex: /```[\s\S]+?```/ }, + { name: 'Markdown code span', regex: /`[^`\n]+`/ }, + { name: 'Markdown emphasis (*)', regex: /(^|[^\w])\*{1,3}[^*\n]+\*{1,3}(?=$|[^\w])/, trimPrefixCapture: true }, + { name: 'Markdown emphasis (_)', regex: /(^|[^\w])_{1,2}[^_\n]+_{1,2}(?=$|[^\w])/, trimPrefixCapture: true } + ]; + + const normalize = (value) => typeof value === 'string' + ? value.replace(/\r\n?/g, '\n').trim() + : ''; + + const findViolation = (text, rules) => { + for (const rule of rules) { + const match = text.match(rule.regex); + if (match) { + const snippet = rule.trimPrefixCapture + ? match[0].slice((match[1] || '').length) + : match[0]; + return { + rule: rule.name, + snippet: snippet.replace(/\s+/g, ' ').trim().slice(0, 80) + }; + } + } + return null; + }; + + for (const field of ['title', 'abstract']) { + const value = normalize(edit.note?.content?.[field]?.value); + if (!value) { + continue; + } + + const texViolation = findViolation(value, texRules); + if (texViolation) { + return Promise.reject(new OpenReviewError({ + name: 'Error', + message: `${field} cannot contain TeX markup (${texViolation.rule}). Detected: ${texViolation.snippet}` + })); + } + + const markdownViolation = findViolation(value, markdownRules); + if (markdownViolation) { + return Promise.reject(new OpenReviewError({ + name: 'Error', + message: `${field} cannot contain Markdown markup (${markdownViolation.rule}). Detected: ${markdownViolation.snippet}` + })); + } + } + + return Promise.resolve(); +} diff --git a/tests/test_arr_venue_v2.py b/tests/test_arr_venue_v2.py index c27a6fd6a..76a4d5642 100644 --- a/tests/test_arr_venue_v2.py +++ b/tests/test_arr_venue_v2.py @@ -27,6 +27,13 @@ arr_ac_max_load_task, arr_sac_max_load_task ) + +ARR_PROCESS_DIR = os.path.join(os.path.dirname(__file__), '../openreview/arr/process') + +def read_arr_process(process_filename): + with open(os.path.join(ARR_PROCESS_DIR, process_filename)) as process_file: + return process_file.read() + # API2 template from ICML class TestARRVenueV2(): @@ -7542,8 +7549,132 @@ def test_commitment_venue(self, client, test_client, openreview_client, helpers) notes = pc_client_v2.get_notes(forum=submssion3.id, domain='aclweb.org/ACL/ARR/2023/August') assert len(notes) == 5 # submission + review + 3 reports - + commitment_submissions = openreview_client.get_notes(invitation='aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/-/Submission', sort='number:asc') + venue = openreview.helpers.get_conference(client, request_form_note.forum) + venue.submission_revision_stage = openreview.stages.SubmissionRevisionStage( + name='Camera_Ready_Revision', + due_date=due_date, + exp_date=due_date + datetime.timedelta(days=1), + only_accepted=False + ) + venue.create_submission_revision_stage() + helpers.await_queue_edit(openreview_client, f'aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/-/Camera_Ready_Revision-0-1', count=1) + + camera_ready_submission = commitment_submissions[0] + + openreview_client.post_invitation_edit( + invitations=venue.get_meta_invitation_id(), + readers=['aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment'], + writers=['aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment'], + signatures=['aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment'], + replacement=False, + invitation=openreview.api.Invitation( + id=f'aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/-/Camera_Ready_Revision', + content={ + 'revision_preprocess_script': { + 'value': read_arr_process('camera_ready_preprocess_content.js') + } + }, + edit={ + 'invitation': { + 'preprocess': read_arr_process('camera_ready_preprocess.js') + } + } + ) + ) + helpers.await_queue_edit(openreview_client, f'aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/-/Camera_Ready_Revision-0-1', count=2) + camera_ready_parent_invitation = openreview_client.get_invitation(f'aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/-/Camera_Ready_Revision') + camera_ready_invitation = openreview_client.get_invitation(f'aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/Submission1/-/Camera_Ready_Revision') + + assert camera_ready_parent_invitation + assert 'revision_preprocess_script' in camera_ready_parent_invitation.content + assert 'title' in camera_ready_parent_invitation.content['revision_preprocess_script']['value'] + assert 'Markdown link' in camera_ready_parent_invitation.content['revision_preprocess_script']['value'] + + assert camera_ready_invitation + assert camera_ready_invitation.preprocess + assert 'revision_preprocess_script' in camera_ready_invitation.preprocess + assert 'client.getInvitations' in camera_ready_invitation.preprocess + + def build_camera_ready_revision_content(submission, title=None, abstract=None): + content = { + 'title': {'value': title if title is not None else submission.content['title']['value']}, + 'abstract': {'value': abstract if abstract is not None else submission.content['abstract']['value']} + } + + for field in ['authors', 'authorids', 'keywords', 'paper_link', 'supplementary_material']: + if field in submission.content: + content[field] = {'value': submission.content[field]['value']} + + return content + + def post_camera_ready_revision(title=None, abstract=None): + return test_client.post_note_edit( + invitation='aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/Submission1/-/Camera_Ready_Revision', + signatures=['aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/Submission1/Authors'], + note=openreview.api.Note( + content=build_camera_ready_revision_content( + camera_ready_submission, + title=title, + abstract=abstract + ) + ) + ) + + invalid_cases = [ + ('title', 'Title with inline math $x$', r'cannot contain TeX markup'), + ('title', r'Title with \textit{TeX} command', r'cannot contain TeX markup'), + ('abstract', 'Abstract with $$x + y$$ display math.', r'cannot contain TeX markup'), + ('abstract', r'Abstract with \begin{equation}a=b\end{equation} environment.', r'cannot contain TeX markup'), + ('abstract', r'Abstract with \(x + y\) math delimiters.', r'cannot contain TeX markup'), + ('title', '**Bold** markdown title', r'cannot contain Markdown markup'), + ('title', '_Italic_ markdown title', r'cannot contain Markdown markup'), + ('abstract', 'Abstract with [Appendix](https://example.com) markdown link.', r'cannot contain Markdown markup'), + ('abstract', 'Abstract with `inline code` markers.', r'cannot contain Markdown markup'), + ('abstract', 'Abstract with markdown autolink.', r'cannot contain Markdown markup') + ] + + for field, value, error_match in invalid_cases: + with pytest.raises(openreview.OpenReviewException, match=error_match): + post_camera_ready_revision(**{field: value}) + + with pytest.raises(openreview.OpenReviewException, match=r'Detected: \$x\$'): + post_camera_ready_revision(title='Camera ready titleo$x$') + + with pytest.raises(openreview.OpenReviewException, match=r'Detected: \$\$x\+y\$\$'): + post_camera_ready_revision(abstract='Camera ready abstracto$$x+y$$') + + valid_cases = [ + { + 'title': '$100K or 100 Days for Better Evaluation', + 'abstract': camera_ready_submission.content['abstract']['value'] + }, + { + 'title': 'Wojood^{Relations} Without Delimited Math', + 'abstract': camera_ready_submission.content['abstract']['value'] + }, + { + 'title': camera_ready_submission.content['title']['value'], + 'abstract': 'We compare [CLS] tokens against plain baselines with ordinary brackets.' + }, + { + 'title': camera_ready_submission.content['title']['value'], + 'abstract': 'A* search and PLAYER* variants remain plain prose here.' + }, + { + 'title': 'Camera-ready title with (parentheses) and [brackets]', + 'abstract': 'A plain URL like https://example.com is allowed because it is not Markdown syntax.' + } + ] + + for case in valid_cases: + revision_edit = post_camera_ready_revision(title=case['title'], abstract=case['abstract']) + helpers.await_queue_edit(openreview_client, edit_id=revision_edit['id']) + updated_submission = openreview_client.get_notes(id=camera_ready_submission.id)[0] + assert updated_submission.content['title']['value'] == case['title'] + assert updated_submission.content['abstract']['value'] == case['abstract'] + venue.invitation_builder.expire_invitation('aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/Senior_Area_Chairs/-/Submission_Group') venue.invitation_builder.expire_invitation('aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/Area_Chairs/-/Submission_Group') venue.invitation_builder.expire_invitation('aclweb.org/ACL/2024/Workshop/C3NLP_ARR_Commitment/Reviewers/-/Submission_Group')