);
}
-}
-
-const RemoveLinksFromCsv = () => {
- const [fileName, setFileName] = useState(false);
- const [uploadMessage, setUploadMessage] = useState(null);
- const [errorMessage, setErrorMessage] = useState(null);
- const handleFileChange = (event) => {
- setFileName(event.target.files[0] || null);
- }
- const handleSubmit = (event) => {
- event.preventDefault();
- setUploadMessage("Uploading...");
- const data = new FormData(event.target);
- data.append('action', 'DELETE');
- const request = new Request(
- '/modtools/links',
- {headers: {'X-CSRFToken': Cookies.get('csrftoken')}}
- );
- fetch(request, {
- method: 'POST',
- mode: 'same-origin',
- credentials: 'same-origin',
- body: data
- }).then(response => {
- if (!response.ok) {
- response.text().then(resp_text => {
- setUploadMessage(null);
- setErrorMessage(resp_text);
- })
- } else {
- response.json().then(resp_json => {
- setUploadMessage(resp_json.data.message);
- setErrorMessage(null);
- if (resp_json.data.errors) {
- let blob = new Blob([resp_json.data.errors], {type: "text/plain;charset=utf-8"});
- saveAs(blob, `${fileName.name.split('.')[0]} - error report - undeleted links.csv`);
- }
- });
- }
- }).catch(error => {
- setUploadMessage(error.message);
- setErrorMessage(null);
- });
- };
- return (
-
- );
-};
-
-const InputRef = ({ id, value, handleChange, handleBlur, error }) => (
-
-);
-InputRef.propTypes = {
- id: PropTypes.number.isRequired,
- value: PropTypes.string.isRequired,
- handleChange: PropTypes.func.isRequired,
- handleBlur: PropTypes.func.isRequired,
- error: PropTypes.bool,
-};
-
-const InputNonRef = ({ name, value, handleChange }) => (
-
-);
-InputNonRef.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- handleChange: PropTypes.func.isRequired,
-};
-
-const DownloadButton = () => (
-
-);
-
-function GetLinks() {
- const [refs, setRefs] = useState({ ref1: '', ref2: '' });
- const [errors, setErrors] = useState({ref2: false});
- const [type, setType] = useState('');
- const [generatedBy, setGeneratedBy] = useState('');
- const [bySegment, setBySegment] = useState(false)
-
- const handleCheck = () => {
- setBySegment(!bySegment)
- }
-
- const handleChange = async (event) => {
- const { name, value } = event.target;
- setRefs(prev => ({ ...prev, [name]: value }));
- if (errors[name]) {
- if (!value) {
- setErrors(prev => ({...prev, [name]: false}));
- }
- else {
- try {
- const response = await Sefaria.getName(value);
- setErrors(prev => ({...prev, [name]: !response.is_ref}));
- } catch (error) {
- console.error(error);
- }
- }
- }
- }
-
-
- const handleBlur = async (event) => {
- const name = event.target.name;
- if (refs[name]) {
- try {
- const response = await Sefaria.getName(refs[name]);
- setErrors(prev => ({ ...prev, [name]: !response.is_ref }));
- } catch (error) {
- console.error(error);
- }
- }
- }
-
- const formReady = () => {
- return refs.ref1 && errors.ref1 === false && errors.ref2 === false;
- }
-
- const linksDownloadLink = () => {
- const queryParams = qs.stringify({ type: (type) ? type : null, generated_by: (generatedBy) ? generatedBy : null },
- { addQueryPrefix: true, skipNulls: true });
- const tool = (bySegment) ? 'index_links' : 'links';
- return `modtools/${tool}/${refs.ref1}/${refs.ref2 || 'all'}${queryParams}`;
- }
return (
-
-
Download links
-
- {formReady() ?
:
}
+ // Outer container is full-width; inner has max-width so side margins allow scrolling
+
+
+ {/* Download/Upload Tools */}
+
+
+
+
+ {/* Links Management */}
+
+
+
+
+ {/* Bulk Editing Tools - temporarily disabled, open ticket to reintroduce */}
+ {/*
*/}
+
+ {/* Commentary Tools - temporarily disabled, open ticket to reintroduce */}
+ {/*
*/}
+
+ {/* Schema Tools - temporarily disabled, open ticket to reintroduce */}
+ {/*
*/}
+
);
}
diff --git a/static/js/modtools/components/BulkDownloadText.jsx b/static/js/modtools/components/BulkDownloadText.jsx
new file mode 100644
index 0000000000..caff134d72
--- /dev/null
+++ b/static/js/modtools/components/BulkDownloadText.jsx
@@ -0,0 +1,189 @@
+/**
+ * BulkDownloadText - Download text versions in bulk by pattern matching
+ *
+ * Allows downloading multiple text versions at once by specifying:
+ * - Index title pattern (e.g., "Genesis" matches "Genesis", "Genesis Rabbah", etc.)
+ * - Version title pattern (e.g., "Kehati", "JPS 1917")
+ * - Language filter (Hebrew, English, or both)
+ * - Output format (text, CSV, JSON)
+ *
+ * Backend endpoint: GET /download/bulk/versions/
+ */
+import React, { useState } from 'react';
+import ModToolsSection from './shared/ModToolsSection';
+
+/**
+ * Help content for Bulk Download Text
+ */
+const HELP_CONTENT = (
+ <>
+
What This Tool Does
+
+ This tool downloads text versions in bulk as files. You can export
+ multiple texts at once by specifying patterns for Index titles and/or Version titles.
+
+
+
How It Works
+
+ Specify patterns: Enter search patterns to match titles.
+ Select language: Choose Hebrew, English, or both.
+ Select format: Choose the output file format.
+ Download: Get a file with all matching text content.
+
+
+
Pattern Matching
+
+
+ Field Description
+
+
+
+ Index Title Pattern
+
+ Matches against the text name (e.g., "Genesis", "Mishnah Berakhot").
+ Partial matches work - "Genesis" matches "Genesis", "Genesis Rabbah", etc.
+
+
+
+ Version Title Pattern
+
+ Matches against the version title (e.g., "William Davidson Edition",
+ "Tanakh: The Holy Scriptures"). Use this to export specific translations.
+
+
+
+
+
+ At least one pattern is required. If both are specified, only versions matching
+ both patterns are downloaded.
+
+
+
Output Formats
+
+
+ Format Description
+
+
+
+ Text (with tags)
+ Plain text with HTML formatting tags preserved
+
+
+ Text (without tags)
+ Plain text with all HTML tags stripped
+
+
+ CSV
+ Spreadsheet format with one segment per row
+
+
+ JSON
+ Structured data format with full metadata
+
+
+
+
+
+
Important Notes:
+
+ Large downloads (entire books, many versions) may take time to generate.
+ Pattern matching is case-sensitive .
+ The Download button activates only when format is selected and at least one pattern is entered.
+
+
+
+
Common Use Cases
+
+ Exporting a specific translation for review or backup
+ Downloading all texts by a specific publisher
+ Getting text content for external analysis tools
+ Creating offline copies of texts
+
+ >
+);
+
+/**
+ * Download button component
+ */
+const DownloadButton = ({ enabled, href }) => {
+ const button = (
+
+ );
+
+ if (enabled && href) {
+ return
{button} ;
+ }
+ return button;
+};
+
+function BulkDownloadText() {
+ const [format, setFormat] = useState('');
+ const [titlePattern, setTitlePattern] = useState('');
+ const [versionTitlePattern, setVersionTitlePattern] = useState('');
+ const [language, setLanguage] = useState('');
+
+ const buildDownloadLink = () => {
+ const args = [
+ format ? `format=${encodeURIComponent(format)}` : '',
+ titlePattern ? `title_pattern=${encodeURIComponent(titlePattern)}` : '',
+ versionTitlePattern ? `version_title_pattern=${encodeURIComponent(versionTitlePattern)}` : '',
+ language ? `language=${encodeURIComponent(language)}` : ''
+ ].filter(a => a).join("&");
+ return "download/bulk/versions/?" + args;
+ };
+
+ const isReady = format && (titlePattern || versionTitlePattern);
+
+ return (
+
+ setTitlePattern(e.target.value)}
+ />
+ setVersionTitlePattern(e.target.value)}
+ />
+ setLanguage(e.target.value)}
+ >
+ Language
+ Hebrew & English
+ Hebrew
+ English
+
+ setFormat(e.target.value)}
+ >
+ File Format
+ Text (with tags)
+ Text (without tags)
+ CSV
+ JSON
+
+
+
+ );
+}
+
+export default BulkDownloadText;
diff --git a/static/js/modtools/components/BulkUploadCSV.jsx b/static/js/modtools/components/BulkUploadCSV.jsx
new file mode 100644
index 0000000000..68ab71b9f6
--- /dev/null
+++ b/static/js/modtools/components/BulkUploadCSV.jsx
@@ -0,0 +1,161 @@
+/**
+ * BulkUploadCSV - Upload text content from CSV files
+ *
+ * Accepts CSV files with text content and creates/updates Version records.
+ * Multiple files can be uploaded at once.
+ *
+ * Backend endpoint: POST /api/text-upload
+ */
+import React, { useState, useRef } from 'react';
+import Cookies from 'js-cookie';
+import ModToolsSection from './shared/ModToolsSection';
+
+/**
+ * Help content for Bulk Upload CSV
+ */
+const HELP_CONTENT = (
+ <>
+
What This Tool Does
+
+ This tool uploads text content from CSV files . Each CSV file contains
+ the content for a text version, formatted with one segment per row.
+
+
+
How It Works
+
+ Prepare CSV: Create CSV file(s) with the correct column format.
+ Select files: Choose one or more CSV files to upload.
+ Upload: Submit to create or update Version records.
+
+
+
CSV Format
+
+ The CSV file should have a specific column structure. Check the backend documentation
+ or existing exports for the exact format required, which typically includes:
+
+
+ Reference column (the segment address)
+ Text content column
+ Optional metadata columns
+
+
+
+ Tip: Use the Bulk Download tool to export an existing text as CSV,
+ then use that file as a template for the expected format.
+
+
+
+
Important Notes:
+
+ CSV must be properly formatted UTF-8 with correct encoding for Hebrew.
+ Multiple files can be selected for batch upload.
+ Existing content may be overwritten - back up first if needed.
+ Invalid refs or format errors will cause that row to fail.
+
+
+
+
Common Use Cases
+
+ Uploading new text content prepared in a spreadsheet
+ Bulk-updating corrected text from editorial review
+ Migrating content from external sources
+ Restoring content from backups
+
+
+
Troubleshooting
+
+ Upload fails - Check CSV encoding (should be UTF-8).
+ Hebrew displays incorrectly - Ensure proper UTF-8 encoding.
+ Partial upload - Some rows may have invalid refs or format.
+
+ >
+);
+
+function BulkUploadCSV() {
+ const [files, setFiles] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const [uploadMessage, setUploadMessage] = useState(null);
+ const [uploadError, setUploadError] = useState(null);
+ const formRef = useRef(null);
+
+ const handleFiles = (event) => {
+ setFiles(Array.from(event.target.files));
+ };
+
+ const uploadFiles = async (event) => {
+ event.preventDefault();
+ setUploading(true);
+ setUploadMessage("Uploading...");
+ setUploadError(null);
+
+ const formData = new FormData();
+ for (let i = 0; i < files.length; i++) {
+ formData.append('texts[]', files[i], files[i].name);
+ }
+
+ try {
+ const response = await fetch('/api/text-upload', {
+ method: 'POST',
+ headers: {
+ 'X-CSRFToken': Cookies.get('csrftoken')
+ },
+ credentials: 'same-origin',
+ body: formData
+ });
+
+ const data = await response.json();
+
+ if (data.status === "ok") {
+ setUploading(false);
+ setUploadMessage(data.message);
+ setUploadError(null);
+ setFiles([]);
+ if (formRef.current) {
+ formRef.current.reset();
+ }
+ } else {
+ setUploadError("Error - " + data.error);
+ setUploading(false);
+ setUploadMessage(data.message);
+ }
+ } catch (err) {
+ setUploadError("Error - " + err.toString());
+ setUploading(false);
+ setUploadMessage(null);
+ }
+ };
+
+ const isReady = !uploading && files.length > 0;
+
+ return (
+
+
+ {uploadMessage && {uploadMessage}
}
+ {uploadError && {uploadError}
}
+
+ );
+}
+
+export default BulkUploadCSV;
diff --git a/static/js/modtools/components/DownloadLinks.jsx b/static/js/modtools/components/DownloadLinks.jsx
new file mode 100644
index 0000000000..fb3a45d699
--- /dev/null
+++ b/static/js/modtools/components/DownloadLinks.jsx
@@ -0,0 +1,274 @@
+/**
+ * DownloadLinks - Download links between refs as CSV
+ *
+ * Options:
+ * - Ref 1 (required): Starting reference
+ * - Ref 2 (optional): Target reference or "all" for entire library
+ * - Type filter: Commentary, Quotation, etc.
+ * - Generated by filter: Filter by who created the links
+ * - Iterate by segments: Include empty segments in output
+ *
+ * Backend endpoint: GET /modtools/links/{ref1}/{ref2}
+ */
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import qs from 'qs';
+import Sefaria from '../../sefaria/sefaria';
+import ModToolsSection from './shared/ModToolsSection';
+
+/**
+ * Help content for DownloadLinks
+ */
+const HELP_CONTENT = (
+ <>
+
What This Tool Does
+
+ This tool exports links as a CSV file for analysis or backup.
+ You can download all connections from a specific text, or filter by type and source.
+
+
+
How It Works
+
+ Enter Ref 1: The primary reference to get links from (required).
+ Enter Ref 2: Optional second reference to find links between two texts.
+ Apply filters: Optionally filter by link type or generator.
+ Download: Get a CSV file with all matching links.
+
+
+
Reference Fields
+
+
+ Field Description
+
+
+
+ Ref 1
+
+ Required. The text reference to get links from. Can be broad ("Genesis")
+ or specific ("Genesis 1:1").
+
+
+
+ Ref 2
+
+ Optional. If blank, downloads links to the entire library (limited to 15k).
+ If specified, downloads only links between Ref 1 and Ref 2.
+
+
+
+
+
+
Filter Options
+
+
+ Filter Description
+
+
+
+ Type
+
+ Filter by link type: Commentary, Quotation, Related, etc.
+ Leave blank for all types.
+
+
+
+ Generated by
+
+ Filter by who/what created the link. Examples: "add_links_from_text",
+ "auto-linker", or a username. Leave blank for all sources.
+
+
+
+ Iterate by segments
+
+ When checked, the output includes every segment in Ref 1, even if it
+ has no links. Useful for finding gaps in link coverage.
+
+
+
+
+
+
Output Format
+
+ The downloaded CSV contains columns for source ref, target ref, link type,
+ and other metadata. This format can be used with the Upload Links or Remove Links tools.
+
+
+
+
Important Notes:
+
+ Results are limited to 15,000 links when Ref 2 is blank.
+ Both refs are validated - red highlight indicates invalid reference.
+ The Download button only becomes active when Ref 1 is valid.
+ Large exports may take a moment to generate.
+
+
+
+
Common Use Cases
+
+ Auditing existing links for a text before making changes
+ Backing up links before bulk deletion
+ Analyzing link coverage and finding gaps
+ Exporting links for external analysis or documentation
+ Getting exact ref formats for use with Remove Links tool
+
+
+
Troubleshooting
+
+ Red input field - Reference is invalid. Check spelling and format.
+ Empty CSV - No links exist matching your criteria.
+ Download button disabled - Ref 1 must be entered and valid.
+ Missing links - Check type/generator filters aren't too restrictive.
+
+ >
+);
+
+/**
+ * Reference input field with validation
+ */
+const InputRef = ({ id, value, handleChange, handleBlur, error }) => (
+
+ Ref{id}
+
+ {(error) ? "Not a valid ref" : ""}
+
+);
+
+InputRef.propTypes = {
+ id: PropTypes.number.isRequired,
+ value: PropTypes.string.isRequired,
+ handleChange: PropTypes.func.isRequired,
+ handleBlur: PropTypes.func.isRequired,
+ error: PropTypes.bool,
+};
+
+/**
+ * Generic text input field
+ */
+const InputNonRef = ({ name, value, handleChange }) => (
+
+ {name.charAt(0).toUpperCase() + name.slice(1)}
+
+
+);
+
+InputNonRef.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ handleChange: PropTypes.func.isRequired,
+};
+
+/**
+ * Download button component
+ */
+const DownloadButton = () => (
+
+);
+
+/**
+ * Main DownloadLinks component
+ */
+function DownloadLinks() {
+ const [refs, setRefs] = useState({ ref1: '', ref2: '' });
+ const [errors, setErrors] = useState({ ref2: false });
+ const [type, setType] = useState('');
+ const [generatedBy, setGeneratedBy] = useState('');
+ const [bySegment, setBySegment] = useState(false);
+
+ const handleCheck = () => {
+ setBySegment(!bySegment);
+ };
+
+ const handleChange = async (event) => {
+ const { name, value } = event.target;
+ setRefs(prev => ({ ...prev, [name]: value }));
+ if (errors[name]) {
+ if (!value) {
+ setErrors(prev => ({ ...prev, [name]: false }));
+ } else {
+ try {
+ const response = await Sefaria.getName(value);
+ setErrors(prev => ({ ...prev, [name]: !response.is_ref }));
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ }
+ };
+
+ const handleBlur = async (event) => {
+ const name = event.target.name;
+ if (refs[name]) {
+ try {
+ const response = await Sefaria.getName(refs[name]);
+ setErrors(prev => ({ ...prev, [name]: !response.is_ref }));
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ };
+
+ const formReady = () => {
+ return refs.ref1 && errors.ref1 === false && errors.ref2 === false;
+ };
+
+ const linksDownloadLink = () => {
+ const queryParams = qs.stringify(
+ { type: (type) ? type : null, generated_by: (generatedBy) ? generatedBy : null },
+ { addQueryPrefix: true, skipNulls: true }
+ );
+ const tool = (bySegment) ? 'index_links' : 'links';
+ return `modtools/${tool}/${refs.ref1}/${refs.ref2 || 'all'}${queryParams}`;
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default DownloadLinks;
diff --git a/static/js/modtools/components/RemoveLinksFromCsv.jsx b/static/js/modtools/components/RemoveLinksFromCsv.jsx
new file mode 100644
index 0000000000..0dc345175b
--- /dev/null
+++ b/static/js/modtools/components/RemoveLinksFromCsv.jsx
@@ -0,0 +1,163 @@
+/**
+ * RemoveLinksFromCsv - Remove links between refs from a CSV file
+ *
+ * CSV format: Two columns with exact refs to unlink
+ * Note: Refs must match exactly (e.g., "Genesis 1" != "Genesis 1:1-31")
+ *
+ * Backend endpoint: POST /modtools/links (with action=DELETE)
+ */
+import React, { useState } from 'react';
+import Cookies from 'js-cookie';
+import { saveAs } from 'file-saver';
+import ModToolsSection from './shared/ModToolsSection';
+import { stripHtmlTags } from '../utils';
+
+/**
+ * Help content for RemoveLinksFromCsv
+ */
+const HELP_CONTENT = (
+ <>
+
What This Tool Does
+
+ This tool deletes links between text references by uploading a CSV file.
+ Use this to remove incorrect, duplicate, or unwanted connections from the library.
+
+
+
How It Works
+
+ Prepare CSV: Create a CSV with two columns of references to unlink.
+ Upload: Submit the file to delete all matching link records.
+ Review: Any refs that couldn't be deleted are reported in an errors file.
+
+
+
CSV Format
+
Same format as Upload Links:
+
+ First row: Column headers (any text, will be skipped)
+ Column 1: Source reference
+ Column 2: Target reference
+
+
+
+
Critical: Exact Match Required
+
+ References must match exactly as they appear in the existing links.
+ The tool does not expand ranges or normalize refs:
+
+
+ Genesis 1 ≠ Genesis 1:1-31
+ Genesis 1:1 ≠ Genesis 1:1-2
+ Refs are case-sensitive and space-sensitive
+
+
+ If you're unsure of the exact format, use the Download Links tool first
+ to export existing links, then use those exact refs in your deletion file.
+
+
+
+
Error Handling
+
+ If some links couldn't be deleted (refs not found, no matching link exists), the tool
+ downloads an error report CSV file listing the failed deletions. Review this file
+ and correct the refs if needed.
+
+
+
+
Important Notes:
+
+ Link deletion is permanent . There is no undo.
+ The order of refs in the CSV doesn't matter (links are bidirectional).
+ Non-existent links are reported as errors but don't cause failure.
+ Consider exporting links first to verify what exists before bulk deletion.
+
+
+
+
Common Use Cases
+
+ Cleaning up incorrectly generated auto-links
+ Removing outdated cross-references
+ Deleting test links after development
+ Reversing a batch upload that had errors
+
+
+
Troubleshooting
+
+ Links not deleted - Check exact ref format. Download existing links to compare.
+ All refs in error report - Likely a format mismatch. Verify refs exist.
+ Some refs deleted, some not - Normal. Error report shows which failed.
+
+ >
+);
+
+const RemoveLinksFromCsv = () => {
+ const [fileName, setFileName] = useState(null);
+ const [uploadMessage, setUploadMessage] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const handleFileChange = (event) => {
+ setFileName(event.target.files[0] || null);
+ };
+
+ const handleSubmit = (event) => {
+ event.preventDefault();
+ setUploadMessage("Uploading...");
+ const data = new FormData(event.target);
+ data.append('action', 'DELETE');
+ const request = new Request(
+ '/modtools/links',
+ { headers: { 'X-CSRFToken': Cookies.get('csrftoken') } }
+ );
+ fetch(request, {
+ method: 'POST',
+ mode: 'same-origin',
+ credentials: 'same-origin',
+ body: data
+ }).then(response => {
+ if (!response.ok) {
+ response.text().then(resp_text => {
+ setUploadMessage(null);
+ setErrorMessage(stripHtmlTags(resp_text));
+ });
+ } else {
+ response.json().then(resp_json => {
+ setUploadMessage(resp_json.data.message);
+ setErrorMessage(null);
+ if (resp_json.data.errors) {
+ let blob = new Blob([resp_json.data.errors], { type: "text/plain;charset=utf-8" });
+ saveAs(blob, `${fileName.name.split('.')[0]} - error report - undeleted links.csv`);
+ }
+ });
+ }
+ }).catch(error => {
+ setUploadMessage(error.message);
+ setErrorMessage(null);
+ });
+ };
+
+ return (
+
+
+
+ {uploadMessage &&
{uploadMessage}
}
+ {errorMessage &&
{errorMessage}
}
+
+
+ );
+};
+
+export default RemoveLinksFromCsv;
diff --git a/static/js/modtools/components/UploadLinksFromCSV.jsx b/static/js/modtools/components/UploadLinksFromCSV.jsx
new file mode 100644
index 0000000000..2ec926f9e8
--- /dev/null
+++ b/static/js/modtools/components/UploadLinksFromCSV.jsx
@@ -0,0 +1,249 @@
+/**
+ * UploadLinksFromCSV - Upload links between refs from a CSV file
+ *
+ * CSV format: Two columns with refs to link
+ * Supports link types: Commentary, Quotation, Related, etc.
+ *
+ * Backend endpoint: POST /modtools/links
+ */
+import React from 'react';
+import Component from 'react-class';
+import Cookies from 'js-cookie';
+import { saveAs } from 'file-saver';
+import ModToolsSection from './shared/ModToolsSection';
+import { stripHtmlTags } from '../utils';
+
+/**
+ * Help content for UploadLinksFromCSV
+ */
+const HELP_CONTENT = (
+ <>
+
What This Tool Does
+
+ This tool creates links between text references by uploading a CSV file.
+ Links connect related passages across Sefaria's library, enabling cross-references
+ and the connections panel that users see when reading.
+
+
+
How It Works
+
+ Prepare CSV: Create a CSV with two columns of references.
+ Select type: Choose the relationship type (Commentary, Quotation, etc.).
+ Name project: Enter a project name for tracking.
+ Upload: Submit to create all the link records.
+
+
+
CSV Format
+
Your CSV file should have:
+
+ First row: Column headers (any text, will be skipped)
+ Column 1: Source reference (e.g., "Genesis 1:1")
+ Column 2: Target reference (e.g., "Rashi on Genesis 1:1:1")
+
+
Example:
+
+{`Source,Target
+Genesis 1:1,Rashi on Genesis 1:1:1
+Genesis 1:2,Rashi on Genesis 1:1:2
+Exodus 20:1,Mekhilta d'Rabbi Yishmael 20:1`}
+
+
+
Link Types
+
+
+ Type Use For
+
+
+
+ Commentary
+ When one text explains another (Rashi explaining Torah)
+
+
+ Quotation
+ When one text directly quotes another
+
+
+ Related
+ General thematic or topical connection
+
+
+ Mesorat hashas
+ Talmudic cross-references (traditional marginal notes)
+
+
+ Ein Mishpat
+ Legal code references from Talmud margins
+
+
+ Reference
+ Generic citation or reference
+
+
+
+
+
Project Name
+
+ The project name is stored with each link for tracking purposes. Use a descriptive
+ name like "Rashi-Torah Links 2024" or "Mekhilta Cross-References". This helps
+ identify links if they need to be removed or audited later.
+
+
+
+
Important Notes:
+
+ Both references must be valid refs that exist in Sefaria.
+ Invalid refs will be reported in an errors file download.
+ Duplicate links are typically ignored (won't create duplicates).
+ Links are bidirectional - viewing either text shows the connection.
+ Large files may take time to process.
+
+
+
+
Common Use Cases
+
+ Importing scholarly cross-references from research
+ Connecting a new commentary to its base text
+ Adding traditional marginal references (Mesorat HaShas, etc.)
+ Batch-creating thematic connections
+
+
+
Troubleshooting
+
+ "Not a valid ref" - Check spelling and format. Use Sefaria's reference format.
+ Errors CSV downloaded - Review failed refs and fix in source file.
+ No links created - Ensure CSV has header row and correct column order.
+
+ >
+);
+
+/**
+ * Link type options for the select dropdown
+ */
+const LINK_TYPE_OPTIONS = [
+ 'Commentary',
+ 'Quotation',
+ 'Related',
+ 'Mesorat hashas',
+ 'Ein Mishpat',
+ 'Reference'
+];
+
+class UploadLinksFromCSV extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ projectName: '',
+ linkType: 'commentary',
+ hasFile: false,
+ uploading: false,
+ uploadMessage: null,
+ uploadResult: null,
+ error: false
+ };
+ }
+
+ isSubmitDisabled() {
+ return !this.state.hasFile || !this.state.projectName.length;
+ }
+
+ handleChange = (event) => {
+ const target = event.target;
+ this.setState({ [target.name]: target.value });
+ }
+
+ handleFileChange = (event) => {
+ this.setState({ hasFile: !!event.target.files[0] });
+ }
+
+ handleSubmit = (event) => {
+ event.preventDefault();
+ this.setState({ uploading: true, uploadMessage: "Uploading..." });
+ const data = new FormData(event.target);
+ const request = new Request(
+ '/modtools/links',
+ { headers: { 'X-CSRFToken': Cookies.get('csrftoken') } }
+ );
+ fetch(request, {
+ method: 'POST',
+ mode: 'same-origin',
+ credentials: 'same-origin',
+ body: data
+ }).then(response => {
+ if (!response.ok) {
+ response.text().then(resp_text => {
+ this.setState({
+ uploading: false,
+ uploadMessage: "",
+ error: true,
+ uploadResult: stripHtmlTags(resp_text)
+ });
+ });
+ } else {
+ response.json().then(resp_json => {
+ this.setState({
+ uploading: false,
+ error: false,
+ uploadMessage: resp_json.data.message,
+ uploadResult: JSON.stringify(resp_json.data.index, undefined, 4)
+ });
+ if (resp_json.data.errors) {
+ let blob = new Blob([resp_json.data.errors], { type: "text/plain;charset=utf-8" });
+ saveAs(blob, 'errors.csv');
+ }
+ });
+ }
+ }).catch(error => {
+ this.setState({ uploading: false, error: true, uploadMessage: error.message });
+ });
+ }
+
+ renderOptions() {
+ return LINK_TYPE_OPTIONS.map((option) => (
+
{option}
+ ));
+ }
+
+ render() {
+ return (
+
+
+
+ {this.state.uploadMessage &&
{this.state.uploadMessage}
}
+ {this.state.error && this.state.uploadResult && (
+
{this.state.uploadResult}
+ )}
+
+
+ );
+ }
+}
+
+export default UploadLinksFromCSV;
diff --git a/static/js/modtools/components/WorkflowyModeratorTool.jsx b/static/js/modtools/components/WorkflowyModeratorTool.jsx
new file mode 100644
index 0000000000..4d3c4d7e6a
--- /dev/null
+++ b/static/js/modtools/components/WorkflowyModeratorTool.jsx
@@ -0,0 +1,323 @@
+/**
+ * WorkflowyModeratorTool - Upload Workflowy OPML exports to create Index/Version records
+ *
+ * Accepts .opml files exported from Workflowy and creates text structure.
+ * Options:
+ * - Create Index Record: Creates the text metadata structure
+ * - Create Version From Notes: Extracts text content from outline notes
+ * - Custom Delimiters: For parsing title language, alt titles, categories
+ * - Term Scheme: Optional term scheme for section names
+ *
+ * Backend endpoint: POST /modtools/upload_text
+ */
+import React from 'react';
+import Component from 'react-class';
+import Cookies from 'js-cookie';
+import { InterfaceText, EnglishText, HebrewText } from '../../Misc';
+import ModToolsSection from './shared/ModToolsSection';
+import { stripHtmlTags } from '../utils';
+
+/**
+ * Help content for WorkflowyModeratorTool
+ */
+const HELP_CONTENT = (
+ <>
+
What This Tool Does
+
+ This tool imports Workflowy outlines (OPML files) and converts them
+ into Sefaria Index records and/or Version text content. Workflowy is an outlining tool
+ that exports hierarchical data in OPML format.
+
+
+
How It Works
+
+ Export: In Workflowy, select your outline and export as OPML.
+ Upload: Select one or more .opml files here.
+ Configure: Choose what to create (Index, Version, or both).
+ Process: The tool parses the outline structure and creates records.
+
+
+
Options Explained
+
+
+ Option Description
+
+
+
+ Create Index Record
+
+ Creates the text's schema structure (hierarchy of chapters, sections, etc.)
+ based on the outline's nesting structure.
+
+
+
+ Create Version From Notes
+
+ If outline items have notes attached, those notes become the text content
+ for a new Version. Each note becomes the content for that segment.
+
+
+
+ Custom Delimiters
+
+ Characters used to parse special formatting in outline titles:
+
+ Title Language delimiter (separates English | Hebrew)
+ Alt Titles delimiter (separates primary, alt1, alt2)
+ Categories delimiter (separates Category, Subcategory)
+
+ Example: using "|" lets you write "Genesis | בראשית" in one item.
+
+
+
+ Term Scheme Name
+
+ Optional. If sections use standardized Terms (like "Chapter", "Verse"),
+ specify the Term Scheme name to automatically link sections to those Terms.
+
+
+
+
+
+
Outline Structure Requirements
+
Your Workflowy outline should be structured like this:
+
+ Top level: The Index title
+ Second level: Major sections (books, parts)
+ Third level: Chapters or subsections
+ Deepest level: Individual segments (verses, paragraphs)
+
+
+ The nesting depth determines the address depth (e.g., Book.Chapter.Verse).
+
+
+
+
Important Notes:
+
+ Files must have .opml extension.
+ Multiple files can be uploaded at once (batch processing).
+ If an Index already exists, the tool may fail or update depending on server settings.
+ Version content comes from notes (not the item titles themselves).
+ Results show which files succeeded and which failed with error details.
+
+
+
+
Common Use Cases
+
+ Creating new text structures from outlines prepared in Workflowy
+ Bulk uploading texts where structure was planned in an outliner
+ Converting hierarchical documents into Sefaria's nested schema format
+
+
+
Troubleshooting
+
+ "Invalid OPML" - File may be corrupted or not valid XML. Re-export from Workflowy.
+ "Index already exists" - Delete or rename the existing Index first.
+ No content created - Make sure "Create Version From Notes" is checked and notes exist.
+
+ >
+);
+
+class WorkflowyModeratorTool extends Component {
+ constructor(props) {
+ super(props);
+ this.wfFileInput = React.createRef();
+ this.state = {
+ c_index: true,
+ c_version: false,
+ delims: '',
+ term_scheme: '',
+ uploading: false,
+ uploadMessage: null,
+ uploadResult: null,
+ error: false,
+ files: []
+ };
+ }
+
+ handleInputChange = (event) => {
+ const target = event.target;
+ const value = target.type === 'checkbox' ? target.checked : target.value;
+ const name = target.name;
+ this.setState({ [name]: value });
+ }
+
+ handleFileChange = (event) => {
+ const files = Array.from(event.target.files);
+ this.setState({ files: files });
+ }
+
+ handleWfSubmit = (event) => {
+ event.preventDefault();
+
+ if (this.state.files.length === 0) {
+ this.setState({ uploadMessage: "Please select at least one file", error: true });
+ return;
+ }
+
+ this.setState({ uploading: true, uploadMessage: `Uploading ${this.state.files.length} file${this.state.files.length > 1 ? 's' : ''}...` });
+
+ const data = new FormData(event.target);
+ const request = new Request(
+ '/modtools/upload_text',
+ { headers: { 'X-CSRFToken': Cookies.get('csrftoken') } }
+ );
+
+ fetch(request, {
+ method: 'POST',
+ mode: 'same-origin',
+ credentials: 'same-origin',
+ body: data,
+ }).then(response => {
+ this.setState({ uploading: false, uploadMessage: "" });
+ if (!response.ok) {
+ response.text().then(resp_text => {
+ this.setState({
+ uploading: false,
+ error: true,
+ uploadResult: stripHtmlTags(resp_text)
+ });
+ });
+ } else {
+ response.json().then(resp_json => {
+ const successes = resp_json.successes || [];
+ const failures = resp_json.failures || [];
+
+ let uploadMessage = "";
+ if (failures.length === 0) {
+ uploadMessage = `Successfully imported ${successes.length} file${successes.length > 1 ? 's' : ''}`;
+ } else if (successes.length === 0) {
+ uploadMessage = `All ${failures.length} file${failures.length > 1 ? 's' : ''} failed`;
+ } else {
+ uploadMessage = `${successes.length} succeeded, ${failures.length} failed`;
+ }
+
+ const parts = [];
+ if (successes.length > 0) {
+ parts.push("Successes:\n" + successes.map(f => ` ✓ ${f}`).join('\n'));
+ }
+ if (failures.length > 0) {
+ parts.push("Failures:\n" + failures.map(f => ` ✗ ${f.file}: ${f.error}`).join('\n'));
+ }
+
+ this.setState({
+ uploading: false,
+ error: failures.length > 0,
+ uploadMessage: uploadMessage,
+ uploadResult: parts.join('\n\n'),
+ files: []
+ });
+
+ if (this.wfFileInput.current) {
+ this.wfFileInput.current.value = '';
+ }
+ });
+ }
+ }).catch(error => {
+ this.setState({ uploading: false, error: true, uploadMessage: error.message });
+ });
+ }
+
+ render() {
+ return (
+
+
+
+ {this.state.uploadMessage && (
+
{this.state.uploadMessage}
+ )}
+ {this.state.uploadResult && (
+
+ )}
+
+
+ );
+ }
+}
+
+export default WorkflowyModeratorTool;
diff --git a/static/js/modtools/index.js b/static/js/modtools/index.js
new file mode 100644
index 0000000000..73348fa1c1
--- /dev/null
+++ b/static/js/modtools/index.js
@@ -0,0 +1,22 @@
+/**
+ * ModTools module exports
+ *
+ * This module provides components for the Moderator Tools panel.
+ * See docs/modtools/MODTOOLS_GUIDE.md for full documentation.
+ */
+
+// Extracted tool components
+export { default as BulkDownloadText } from './components/BulkDownloadText';
+export { default as BulkUploadCSV } from './components/BulkUploadCSV';
+export { default as WorkflowyModeratorTool } from './components/WorkflowyModeratorTool';
+export { default as UploadLinksFromCSV } from './components/UploadLinksFromCSV';
+export { default as DownloadLinks } from './components/DownloadLinks';
+export { default as RemoveLinksFromCsv } from './components/RemoveLinksFromCsv';
+
+// Shared UI components
+export {
+ ModToolsSection,
+ HelpButton,
+ StatusMessage,
+ MESSAGE_TYPES
+} from './components/shared';
diff --git a/static/js/modtools/utils/index.js b/static/js/modtools/utils/index.js
new file mode 100644
index 0000000000..7f15ceca06
--- /dev/null
+++ b/static/js/modtools/utils/index.js
@@ -0,0 +1,7 @@
+/**
+ * ModTools Utilities
+ *
+ * Shared utility functions used across modtools components.
+ */
+
+export { default as stripHtmlTags } from './stripHtmlTags';
diff --git a/static/js/modtools/utils/stripHtmlTags.js b/static/js/modtools/utils/stripHtmlTags.js
new file mode 100644
index 0000000000..13f7bcda69
--- /dev/null
+++ b/static/js/modtools/utils/stripHtmlTags.js
@@ -0,0 +1,21 @@
+/**
+ * Strip HTML tags from a string for safe display
+ * Uses regex to remove HTML tags - safe approach without DOM parsing
+ *
+ * @param {string} text - The string potentially containing HTML tags
+ * @returns {string} - The string with HTML tags removed
+ */
+const stripHtmlTags = (text) => {
+ if (!text) return '';
+ // Remove HTML tags using regex
+ return text
+ .replace(/<[^>]*>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .trim();
+};
+
+export default stripHtmlTags;
From cf432e9ae98c5e5dd30002f6600f91445b404750 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Mon, 12 Jan 2026 08:31:11 +0200
Subject: [PATCH 04/26] fix: Load modtools.css via HTML link tag for CI
compatibility
Move CSS loading from JavaScript import (Webpack bundled) to a static
tag in base.html. This ensures the CSS is collected by Django's
collectstatic and served properly in CI environments.
Co-Authored-By: Claude Opus 4.5
---
static/js/ModeratorToolsPanel.jsx | 3 +--
templates/base.html | 2 ++
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/static/js/ModeratorToolsPanel.jsx b/static/js/ModeratorToolsPanel.jsx
index a38d50ef14..9411719b7b 100644
--- a/static/js/ModeratorToolsPanel.jsx
+++ b/static/js/ModeratorToolsPanel.jsx
@@ -22,8 +22,7 @@
*/
import Sefaria from './sefaria/sefaria';
-// Import modtools styles
-import '../css/modtools.css';
+// Note: modtools.css is loaded via tag in base.html for CI compatibility
// Import tool components
import BulkDownloadText from './modtools/components/BulkDownloadText';
diff --git a/templates/base.html b/templates/base.html
index de9ddc1404..035f1910ab 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -100,6 +100,8 @@
+
+
{% block static_css %}
{% if not html %}
From 96cdca99f31930b78298a70527fc5bafeb22a568 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Mon, 12 Jan 2026 10:20:29 +0200
Subject: [PATCH 05/26] style(modtools): Reduce whitespace and element sizes
for compact UI
Adjusted design system tokens and component styles:
- Reduced spacing tokens by ~50% (xs: 2px, sm: 4px, md: 8px, lg: 12px, xl: 16px)
- Reduced input padding from 12px 16px to 8px 12px
- Reduced button padding from 12px 24px to 8px 16px
- Reduced font sizes (inputs: 13px, labels: 13px, buttons: 13px)
- Reduced input height from 48px to 36px
- Reduced section title font size from 24px to 18px
- Reduced page header font size from 28px to 22px
- Reduced checkbox size from 18px to 14px
- Made border radius smaller (sm instead of md for most elements)
Co-Authored-By: Claude Opus 4.5
---
static/css/modtools.css | 116 ++++++++++++++++++++--------------------
1 file changed, 59 insertions(+), 57 deletions(-)
diff --git a/static/css/modtools.css b/static/css/modtools.css
index 2cdca45b5b..23b948694b 100644
--- a/static/css/modtools.css
+++ b/static/css/modtools.css
@@ -114,14 +114,14 @@
/*
* SPACING
- * Based on 4px grid
+ * Based on 4px grid - Compact design
*/
- --mt-space-xs: 4px; /* Tight spacing */
- --mt-space-sm: 8px; /* Small gaps */
- --mt-space-md: 16px; /* Medium gaps, default padding */
- --mt-space-lg: 24px; /* Large gaps, section spacing */
- --mt-space-xl: 32px; /* Extra large, card padding */
- --mt-space-2xl: 48px; /* Major section breaks */
+ --mt-space-xs: 2px; /* Tight spacing */
+ --mt-space-sm: 4px; /* Small gaps */
+ --mt-space-md: 8px; /* Medium gaps, default padding */
+ --mt-space-lg: 12px; /* Large gaps, section spacing */
+ --mt-space-xl: 16px; /* Extra large, card padding */
+ --mt-space-2xl: 24px; /* Major section breaks */
/*
* BORDERS & EFFECTS
@@ -154,7 +154,7 @@
* LAYOUT
*/
--mt-max-width: 1100px;
- --mt-input-height: 48px; /* Standard input height (12px padding * 2 + line-height) */
+ --mt-input-height: 36px; /* Standard input height - compact */
}
/* ==========================================================================
@@ -163,12 +163,12 @@
.modTools {
width: 100%;
min-height: 100vh;
- padding: var(--mt-space-xl) var(--mt-space-lg);
- padding-bottom: 80px; /* Generous bottom margin */
+ padding: var(--mt-space-lg) var(--mt-space-md);
+ padding-bottom: 40px; /* Bottom margin */
background: var(--mt-bg-page);
font-family: var(--mt-font-body);
- font-size: var(--mt-text-base);
- line-height: var(--mt-leading-relaxed);
+ font-size: var(--mt-text-sm);
+ line-height: var(--mt-leading-normal);
color: var(--mt-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -196,11 +196,11 @@
content: "Moderator Tools";
display: block;
font-family: var(--mt-font-display);
- font-size: 28px;
+ font-size: 22px;
font-weight: var(--mt-font-semibold);
color: var(--mt-primary);
- padding: var(--mt-space-lg) 0;
- margin-bottom: var(--mt-space-xl);
+ padding: var(--mt-space-md) 0;
+ margin-bottom: var(--mt-space-lg);
border-bottom: 2px solid var(--mt-border);
letter-spacing: -0.01em;
}
@@ -212,10 +212,10 @@
/* --- Section Cards --- */
.modTools .modToolsSection {
background: var(--mt-bg-card);
- border-radius: var(--mt-radius-lg);
- padding: var(--mt-space-xl);
- margin-bottom: var(--mt-space-lg);
- box-shadow: var(--mt-shadow-card);
+ border-radius: var(--mt-radius-sm);
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-sm);
+ box-shadow: var(--mt-shadow-sm);
border: 1px solid var(--mt-border);
transition: box-shadow var(--mt-transition);
overflow: visible;
@@ -228,11 +228,11 @@
/* Section Title */
.modTools .dlSectionTitle {
font-family: var(--mt-font-display);
- font-size: var(--mt-text-2xl);
+ font-size: var(--mt-text-lg);
font-weight: var(--mt-font-semibold);
color: var(--mt-primary);
- margin: 0 0 var(--mt-space-sm) 0;
- padding-bottom: var(--mt-space-md);
+ margin: 0 0 var(--mt-space-xs) 0;
+ padding-bottom: var(--mt-space-sm);
border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
letter-spacing: -0.01em;
line-height: var(--mt-leading-tight);
@@ -247,9 +247,9 @@
/* Section subtitle/description */
.modTools .sectionDescription {
- font-size: 14px;
+ font-size: 13px;
color: var(--mt-text-secondary);
- margin-bottom: var(--mt-space-lg);
+ margin-bottom: var(--mt-space-md);
line-height: var(--mt-leading-normal);
}
@@ -260,10 +260,10 @@
/* --- Labels --- */
.modTools label {
display: block;
- font-size: 14px;
- font-weight: var(--mt-font-semibold);
+ font-size: 13px;
+ font-weight: var(--mt-font-medium);
color: var(--mt-text);
- margin-bottom: var(--mt-space-sm);
+ margin-bottom: var(--mt-space-xs);
}
/* --- Input Base Styles --- */
@@ -276,15 +276,15 @@
display: block;
width: 100%;
max-width: 100%;
- padding: 12px 16px;
- margin-bottom: var(--mt-space-md);
+ padding: 6px 10px;
+ margin-bottom: var(--mt-space-sm);
font-family: var(--mt-font-body);
- font-size: var(--mt-text-base);
+ font-size: var(--mt-text-sm);
line-height: var(--mt-leading-normal);
color: var(--mt-text);
background: var(--mt-bg-input);
- border: var(--mt-border-width-thick) solid var(--mt-border);
- border-radius: var(--mt-radius-md);
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-sm);
transition: all var(--mt-transition);
box-sizing: border-box;
}
@@ -375,7 +375,7 @@
flex-shrink: 0;
white-space: nowrap;
/* Standard button sizing - not stretched */
- padding: 12px 24px;
+ padding: 6px 14px;
min-width: auto;
width: auto;
}
@@ -549,15 +549,15 @@
display: inline-flex;
align-items: center;
justify-content: center;
- gap: var(--mt-space-sm);
- padding: 12px 24px;
+ gap: var(--mt-space-xs);
+ padding: 6px 14px;
background: var(--mt-primary);
color: var(--mt-text-on-primary);
font-family: var(--mt-font-body);
- font-size: 14px;
+ font-size: 12px;
font-weight: 600;
border: none;
- border-radius: var(--mt-radius-md);
+ border-radius: 4px;
cursor: pointer;
transition: all var(--mt-transition);
text-decoration: none;
@@ -586,7 +586,7 @@
background: transparent;
color: var(--mt-primary);
border: 2px solid var(--mt-primary);
- padding: 10px 22px;
+ padding: 6px 14px;
}
.modTools .modtoolsButton.secondary:hover {
@@ -604,8 +604,8 @@
/* Small button */
.modTools .modtoolsButton.small {
- padding: 8px 16px;
- font-size: 13px;
+ padding: 5px 10px;
+ font-size: 12px;
}
/* Loading spinner in buttons */
@@ -635,14 +635,14 @@
.modTools input[type="file"] {
display: block;
width: 100%;
- padding: var(--mt-space-lg);
- margin-bottom: var(--mt-space-md);
+ padding: var(--mt-space-md);
+ margin-bottom: var(--mt-space-sm);
background: var(--mt-bg-subtle);
border: 2px dashed var(--mt-border);
- border-radius: var(--mt-radius-md);
+ border-radius: var(--mt-radius-sm);
cursor: pointer;
font-family: var(--mt-font-body);
- font-size: 14px;
+ font-size: 13px;
color: var(--mt-text-secondary);
}
@@ -652,14 +652,14 @@
}
.modTools input[type="file"]::file-selector-button {
- padding: 10px 20px;
- margin-right: var(--mt-space-md);
+ padding: 6px 12px;
+ margin-right: var(--mt-space-sm);
background: var(--mt-primary);
color: white;
border: none;
border-radius: var(--mt-radius-sm);
font-family: var(--mt-font-body);
- font-size: 14px;
+ font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background var(--mt-transition);
@@ -931,7 +931,7 @@
8. FIELD GROUPS
========================================================================== */
.modTools .fieldGroup {
- margin-bottom: var(--mt-space-lg);
+ margin-bottom: var(--mt-space-sm);
}
.modTools .fieldGroup label {
@@ -1228,14 +1228,14 @@
display: inline-flex;
align-items: center;
justify-content: center;
- padding: 12px 24px;
+ padding: 6px 14px;
background: var(--mt-primary);
color: var(--mt-text-on-primary);
font-family: var(--mt-font-body);
- font-size: 14px;
+ font-size: 12px;
font-weight: 600;
border: none;
- border-radius: var(--mt-radius-md);
+ border-radius: 4px;
cursor: pointer;
transition: all var(--mt-transition);
align-self: flex-start;
@@ -1254,8 +1254,8 @@
Checkbox Styling
========================================================================== */
.modTools input[type="checkbox"] {
- width: 18px;
- height: 18px;
+ width: 14px;
+ height: 14px;
margin: 0;
cursor: pointer;
accent-color: var(--mt-primary);
@@ -1264,20 +1264,22 @@
.modTools .checkboxLabel {
display: inline-flex;
align-items: center;
- gap: var(--mt-space-sm);
+ gap: var(--mt-space-xs);
cursor: pointer;
- padding: var(--mt-space-xs) 0;
+ padding: 0;
font-weight: 400;
+ font-size: 13px;
}
.modTools label:has(input[type="checkbox"]) {
display: flex;
flex-direction: row;
align-items: center;
- gap: var(--mt-space-sm);
+ gap: var(--mt-space-xs);
font-weight: 400;
cursor: pointer;
- padding: var(--mt-space-sm) 0;
+ padding: var(--mt-space-xs) 0;
+ font-size: 13px;
}
/* ==========================================================================
From 0a61d4c4f1d2fd75d1df4db4a2fc4e5390f72490 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Mon, 12 Jan 2026 10:49:12 +0200
Subject: [PATCH 06/26] refactor(modtools): Use design system classes in
DownloadLinks component
- Replace inline styles with design system classes (fieldGroup, hasError, fieldError)
- Remove tags between form fields
- Wrap form sections with fieldGroupSection class
- Reduce gap spacing in legacy form CSS
- Make fieldset and fieldGroupSection more compact
Co-Authored-By: Claude Opus 4.5
---
static/css/modtools.css | 17 +++---
.../js/modtools/components/DownloadLinks.jsx | 57 +++++++++----------
2 files changed, 36 insertions(+), 38 deletions(-)
diff --git a/static/css/modtools.css b/static/css/modtools.css
index 23b948694b..847088410e 100644
--- a/static/css/modtools.css
+++ b/static/css/modtools.css
@@ -958,10 +958,10 @@
/* Field group sections */
.modTools .fieldGroupSection {
- margin-bottom: var(--mt-space-xl);
- padding: var(--mt-space-lg);
+ margin-bottom: var(--mt-space-md);
+ padding: var(--mt-space-sm) var(--mt-space-md);
background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-md);
+ border-radius: var(--mt-radius-sm);
border: 1px solid var(--mt-border);
}
@@ -1199,7 +1199,7 @@
.modTools .remove-links-csv {
display: flex;
flex-direction: column;
- gap: var(--mt-space-md);
+ gap: var(--mt-space-xs);
}
.modTools .getLinks form,
@@ -1207,20 +1207,21 @@
.modTools .remove-links-csv form {
display: flex;
flex-direction: column;
- gap: var(--mt-space-md);
+ gap: var(--mt-space-xs);
}
.modTools .getLinks fieldset {
border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-md);
- padding: var(--mt-space-lg);
+ border-radius: var(--mt-radius-sm);
+ padding: var(--mt-space-sm) var(--mt-space-md);
margin: 0;
background: var(--mt-bg-subtle);
}
.modTools .getLinks fieldset legend {
- padding: 0 var(--mt-space-sm);
+ padding: 0 var(--mt-space-xs);
font-weight: 600;
+ font-size: 12px;
}
/* Submit buttons in legacy forms */
diff --git a/static/js/modtools/components/DownloadLinks.jsx b/static/js/modtools/components/DownloadLinks.jsx
index fb3a45d699..28e003596d 100644
--- a/static/js/modtools/components/DownloadLinks.jsx
+++ b/static/js/modtools/components/DownloadLinks.jsx
@@ -127,19 +127,19 @@ const HELP_CONTENT = (
* Reference input field with validation
*/
const InputRef = ({ id, value, handleChange, handleBlur, error }) => (
-
- Ref{id}
+
+
Ref{id}
-
{(error) ? "Not a valid ref" : ""}
-
+ {error &&
Not a valid ref }
+
);
InputRef.propTypes = {
@@ -154,8 +154,8 @@ InputRef.propTypes = {
* Generic text input field
*/
const InputNonRef = ({ name, value, handleChange }) => (
-
- {name.charAt(0).toUpperCase() + name.slice(1)}
+
+ {name.charAt(0).toUpperCase() + name.slice(1)}
(
onChange={handleChange}
placeholder="any"
/>
-
+
);
InputNonRef.propTypes = {
@@ -245,28 +245,25 @@ function DownloadLinks() {
titleHe="הורדת קישורים"
helpContent={HELP_CONTENT}
>
-
+
);
}
From f634e9b5f3bfdbe10203ba4aaa7711a3e0cfcf1c Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Thu, 8 Jan 2026 14:39:51 +0200
Subject: [PATCH 07/26] feat(modtools): Add IndexSelector and field metadata
constants
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add IndexSelector component for card-based index selection grid
- Add VERSION_FIELD_METADATA for BulkVersionEditor field definitions
- Add INDEX_FIELD_METADATA for future BulkIndexEditor use
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
.../components/shared/IndexSelector.jsx | 180 ++++++++++++
static/js/modtools/components/shared/index.js | 1 +
static/js/modtools/constants/fieldMetadata.js | 278 ++++++++++++++++++
3 files changed, 459 insertions(+)
create mode 100644 static/js/modtools/components/shared/IndexSelector.jsx
create mode 100644 static/js/modtools/constants/fieldMetadata.js
diff --git a/static/js/modtools/components/shared/IndexSelector.jsx b/static/js/modtools/components/shared/IndexSelector.jsx
new file mode 100644
index 0000000000..65cf55317b
--- /dev/null
+++ b/static/js/modtools/components/shared/IndexSelector.jsx
@@ -0,0 +1,180 @@
+/**
+ * IndexSelector - List-based display for selecting indices
+ *
+ * Shared component used by BulkVersionEditor, BulkIndexEditor (disabled), AutoLinkCommentaryTool (disabled)
+ * to display indices in a compact list with filtering.
+ *
+ * Features:
+ * - List-based layout with rows (scrollable, configurable max-height)
+ * - Text search filtering (searches both title and categories)
+ * - Select All checkbox in header (toggles selection of filtered items)
+ * - Visual distinction for selected items (highlighted background)
+ * - Category display inline (when categories are provided in index objects)
+ *
+ * Props:
+ * - indices: Array<{title: string, categories?: string[]}> - Array of index objects with title and optional categories
+ * - selectedIndices: Set - Set of currently selected index titles
+ * - onSelectionChange: (Set) => void - Callback when selection changes
+ * - label: string - Label for the items (e.g., "texts", "indices", "commentaries")
+ *
+ * Parent components should:
+ * - Transform API response to combine indices and metadata into single array
+ * - Implement their own Clear Search button and searched state
+ *
+ * For AI agents: This component manages a Set of selected index titles.
+ * The onSelectionChange callback receives the new Set when selection changes.
+ */
+import React, { useState, useMemo } from 'react';
+import PropTypes from 'prop-types';
+
+const IndexSelector = ({
+ indices,
+ selectedIndices,
+ onSelectionChange,
+ label = 'texts',
+ maxHeight = null // Set to null to let page scroll; set value like '400px' for inner scroll
+}) => {
+ const [searchFilter, setSearchFilter] = useState('');
+
+ // Filter indices based on search (searches both title and categories)
+ const filteredIndices = useMemo(() => {
+ if (!searchFilter.trim()) return indices;
+ const search = searchFilter.toLowerCase();
+ return indices.filter(item => {
+ const titleMatch = item.title.toLowerCase().includes(search);
+ const categoryMatch = item.categories?.some(cat =>
+ cat.toLowerCase().includes(search)
+ );
+ return titleMatch || categoryMatch;
+ });
+ }, [indices, searchFilter]);
+
+ if (!indices || indices.length === 0) return null;
+
+ const selectAll = () => {
+ // Select all currently filtered indices
+ const toSelect = new Set(selectedIndices);
+ filteredIndices.forEach(item => toSelect.add(item.title));
+ onSelectionChange(toSelect);
+ };
+
+ const deselectAll = () => {
+ // Deselect all currently filtered indices
+ const toKeep = new Set(selectedIndices);
+ filteredIndices.forEach(item => toKeep.delete(item.title));
+ onSelectionChange(toKeep);
+ };
+
+ const toggleOne = (title, checked) => {
+ const newSet = new Set(selectedIndices);
+ if (checked) {
+ newSet.add(title);
+ } else {
+ newSet.delete(title);
+ }
+ onSelectionChange(newSet);
+ };
+
+ const allFilteredSelected = filteredIndices.every(item => selectedIndices.has(item.title));
+
+ // Get display category for an index (first 2 categories joined)
+ const getDisplayCategory = (item) => {
+ if (item.categories && item.categories.length > 0) {
+ return item.categories.slice(0, 2).join(' • ');
+ }
+ return null;
+ };
+
+ return (
+
+ {/* Header with count and search */}
+
+
+ Found {indices.length} {label} with this version
+
+
+
+ {selectedIndices.size} of {indices.length} selected
+
+
+ 0}
+ onChange={e => e.target.checked ? selectAll() : deselectAll()}
+ />
+ Select All
+
+
+
+
+ {/* Search filter */}
+
+ setSearchFilter(e.target.value)}
+ />
+ {searchFilter && (
+ setSearchFilter('')}
+ type="button"
+ aria-label="Clear search"
+ >
+ ×
+
+ )}
+
+
+ {/* Index List */}
+
+ {filteredIndices.length === 0 ? (
+
+ No {label} match "{searchFilter}"
+
+ ) : (
+ filteredIndices.map(item => {
+ const isSelected = selectedIndices.has(item.title);
+ const category = getDisplayCategory(item);
+
+ return (
+
toggleOne(item.title, !isSelected)}
+ >
+ {
+ e.stopPropagation();
+ toggleOne(item.title, e.target.checked);
+ }}
+ />
+ {item.title}
+ {category && (
+ {category}
+ )}
+
+ );
+ })
+ )}
+
+
+ );
+};
+
+IndexSelector.propTypes = {
+ indices: PropTypes.arrayOf(PropTypes.shape({
+ title: PropTypes.string.isRequired,
+ categories: PropTypes.arrayOf(PropTypes.string)
+ })).isRequired,
+ selectedIndices: PropTypes.instanceOf(Set).isRequired,
+ onSelectionChange: PropTypes.func.isRequired,
+ label: PropTypes.string,
+ maxHeight: PropTypes.string
+};
+
+export default IndexSelector;
diff --git a/static/js/modtools/components/shared/index.js b/static/js/modtools/components/shared/index.js
index d471307687..847b4b0533 100644
--- a/static/js/modtools/components/shared/index.js
+++ b/static/js/modtools/components/shared/index.js
@@ -6,3 +6,4 @@
export { default as ModToolsSection } from './ModToolsSection';
export { default as HelpButton } from './HelpButton';
export { default as StatusMessage, MESSAGE_TYPES } from './StatusMessage';
+export { default as IndexSelector } from './IndexSelector';
diff --git a/static/js/modtools/constants/fieldMetadata.js b/static/js/modtools/constants/fieldMetadata.js
new file mode 100644
index 0000000000..db139dffae
--- /dev/null
+++ b/static/js/modtools/constants/fieldMetadata.js
@@ -0,0 +1,278 @@
+/**
+ * Field metadata definitions for Index and Version editing tools.
+ *
+ * This file centralizes the field configuration for bulk editing operations,
+ * making it easier to maintain consistency across the modtools components.
+ *
+ * Field types:
+ * - text: Single-line text input
+ * - textarea: Multi-line text input
+ * - select: Dropdown with predefined options
+ * - array: Comma-separated list that converts to array
+ * - number: Numeric input with optional min/max
+ * - daterange: Date or date range (year or [start, end] format)
+ *
+ * For AI agents: When adding new fields, ensure the backend model
+ * (sefaria/model/text.py) supports the field as an optional_attr.
+ */
+
+/**
+ * INDEX_FIELD_METADATA
+ *
+ * Defines editable fields for Index records (text metadata).
+ * These correspond to fields in the Index model (sefaria/model/text.py).
+ */
+export const INDEX_FIELD_METADATA = {
+ "enDesc": {
+ label: "English Description",
+ type: "textarea",
+ placeholder: "A description of the text in English"
+ },
+ "enShortDesc": {
+ label: "Short English Description",
+ type: "textarea",
+ placeholder: "Brief description (1-2 sentences)"
+ },
+ "heDesc": {
+ label: "Hebrew Description",
+ type: "textarea",
+ placeholder: "תיאור הטקסט בעברית",
+ dir: "rtl"
+ },
+ "heShortDesc": {
+ label: "Hebrew Short Description",
+ type: "textarea",
+ placeholder: "תיאור קצר (משפט או שניים)",
+ dir: "rtl"
+ },
+ "categories": {
+ label: "Category",
+ type: "array",
+ placeholder: "Select category...",
+ help: "The category path determines where this text appears in the library"
+ },
+ "authors": {
+ label: "Authors",
+ type: "array",
+ placeholder: "Author names (one per line or comma-separated)",
+ help: "Enter author names. Backend expects a list of strings. Use 'auto' to detect from title.",
+ auto: true
+ },
+ "compDate": {
+ label: "Composition Date",
+ type: "daterange",
+ placeholder: "[1040, 1105] or 1105 or -500",
+ help: "Year or range [start, end]. Negative for BCE. Arrays auto-convert to single year if identical."
+ },
+ "compPlace": {
+ label: "Composition Place",
+ type: "text",
+ placeholder: "e.g., 'Troyes, France'"
+ },
+ "heCompPlace": {
+ label: "Hebrew Composition Place",
+ type: "text",
+ placeholder: "למשל: 'טרואה, צרפת'",
+ dir: "rtl"
+ },
+ "pubDate": {
+ label: "Publication Date",
+ type: "daterange",
+ placeholder: "[1475, 1475] or 1475",
+ help: "First publication year or range"
+ },
+ "pubPlace": {
+ label: "Publication Place",
+ type: "text",
+ placeholder: "e.g., 'Venice, Italy'"
+ },
+ "hePubPlace": {
+ label: "Hebrew Publication Place",
+ type: "text",
+ placeholder: "למשל: 'ונציה, איטליה'",
+ dir: "rtl"
+ },
+ "toc_zoom": {
+ label: "TOC Zoom Level",
+ type: "number",
+ placeholder: "0-10",
+ help: "Controls how deep the table of contents displays by default (0=fully expanded). Must be an integer.",
+ validate: (value) => {
+ if (value === "" || value === null || value === undefined) return true;
+ const num = parseInt(value);
+ return !isNaN(num) && num >= 0 && num <= 10;
+ }
+ },
+ "dependence": {
+ label: "Dependence Type",
+ type: "select",
+ placeholder: "Select dependence type",
+ help: "Is this text dependent on another text? (e.g., Commentary on a base text)",
+ options: [
+ { value: "", label: "None" },
+ { value: "Commentary", label: "Commentary" },
+ { value: "Targum", label: "Targum" },
+ { value: "auto", label: "Auto-detect from title" }
+ ],
+ auto: true
+ },
+ "base_text_titles": {
+ label: "Base Text Titles",
+ type: "array",
+ placeholder: "Base text names (one per line or comma-separated)",
+ help: "Enter base text names that this commentary depends on. Use 'auto' to detect from title (e.g., 'Genesis' for 'Rashi on Genesis'). Backend expects a list of strings.",
+ auto: true
+ },
+ "collective_title": {
+ label: "English Collective Title",
+ type: "text",
+ placeholder: "Collective title or 'auto' for auto-detection",
+ help: "Enter collective title or type 'auto' to detect from title (e.g., 'Rashi' for 'Rashi on Genesis'). If Hebrew equivalent is provided, term will be created automatically.",
+ auto: true
+ },
+ "he_collective_title": {
+ label: "Hebrew Collective Title (Term)",
+ type: "text",
+ placeholder: "Hebrew equivalent of collective title",
+ help: "Hebrew equivalent of the collective title. If the term doesn't exist, it will be created automatically with both English and Hebrew titles.",
+ dir: "rtl"
+ }
+};
+
+/**
+ * VERSION_FIELD_METADATA
+ *
+ * Defines editable fields for Version records (text versions/translations).
+ * These correspond to optional_attrs in the Version model (sefaria/model/text.py:1310).
+ *
+ * Note: "versionTitle" and "language" are required fields, not optional.
+ *
+ * Special field: "status"
+ * - When set to "locked", non-staff users cannot edit the version
+ * - See sefaria/tracker.py:33 for enforcement logic
+ */
+export const VERSION_FIELD_METADATA = {
+ "versionTitle": {
+ label: "Version Title",
+ type: "text",
+ placeholder: "e.g., 'Torat Emet 357'",
+ help: "The unique identifier for this version",
+ required: true
+ },
+ "versionTitleInHebrew": {
+ label: "Hebrew Version Title",
+ type: "text",
+ placeholder: "כותרת הגרסה בעברית",
+ dir: "rtl"
+ },
+ "versionSource": {
+ label: "Version Source URL",
+ type: "text",
+ placeholder: "https://...",
+ help: "URL to the original source of this text version"
+ },
+ "license": {
+ label: "License",
+ type: "select",
+ placeholder: "Select license",
+ options: [
+ { value: "", label: "None specified" },
+ { value: "CC-BY", label: "CC-BY" },
+ { value: "CC-BY-SA", label: "CC-BY-SA" },
+ { value: "CC-BY-NC", label: "CC-BY-NC" },
+ { value: "CC-BY-NC-SA", label: "CC-BY-NC-SA" },
+ { value: "CC0", label: "CC0 (Public Domain)" },
+ { value: "Public Domain", label: "Public Domain" },
+ { value: "Copyright", label: "Copyright" }
+ ]
+ },
+ "status": {
+ label: "Status",
+ type: "select",
+ placeholder: "Select status",
+ help: "When set to 'locked', non-staff users cannot edit this version (see tracker.py:33)",
+ options: [
+ { value: "", label: "None (editable)" },
+ { value: "locked", label: "Locked (staff only)" }
+ ]
+ },
+ "priority": {
+ label: "Priority",
+ type: "number",
+ placeholder: "e.g., 1.0",
+ help: "Float value for ordering. Higher priority versions appear first."
+ },
+ "digitizedBySefaria": {
+ label: "Digitized by Sefaria",
+ type: "select",
+ options: [
+ { value: "", label: "Not specified" },
+ { value: "true", label: "Yes" },
+ { value: "false", label: "No" }
+ ]
+ },
+ "isPrimary": {
+ label: "Is Primary Version",
+ type: "select",
+ help: "Mark as the primary version for this language",
+ options: [
+ { value: "", label: "Not specified" },
+ { value: "true", label: "Yes" },
+ { value: "false", label: "No" }
+ ]
+ },
+ "isSource": {
+ label: "Is Source (Original)",
+ type: "select",
+ help: "Is this the original text (not a translation)?",
+ options: [
+ { value: "", label: "Not specified" },
+ { value: "true", label: "Yes (original)" },
+ { value: "false", label: "No (translation)" }
+ ]
+ },
+ "versionNotes": {
+ label: "Version Notes (English)",
+ type: "textarea",
+ placeholder: "Notes about this version in English"
+ },
+ "versionNotesInHebrew": {
+ label: "Version Notes (Hebrew)",
+ type: "textarea",
+ placeholder: "הערות על גרסה זו",
+ dir: "rtl"
+ },
+ "purchaseInformationURL": {
+ label: "Purchase URL",
+ type: "text",
+ placeholder: "https://..."
+ },
+ "purchaseInformationImage": {
+ label: "Purchase Image URL",
+ type: "text",
+ placeholder: "https://..."
+ },
+ "direction": {
+ label: "Text Direction",
+ type: "select",
+ help: "Override text direction (rarely needed)",
+ options: [
+ { value: "", label: "Auto (based on language)" },
+ { value: "rtl", label: "Right-to-Left (RTL)" },
+ { value: "ltr", label: "Left-to-Right (LTR)" }
+ ]
+ }
+};
+
+/**
+ * BASE_TEXT_MAPPING_OPTIONS
+ *
+ * Options for the base_text_mapping field used in commentary linking.
+ * See sefaria/model/link.py for implementation details.
+ */
+export const BASE_TEXT_MAPPING_OPTIONS = [
+ { value: "many_to_one_default_only", label: "many_to_one_default_only (Mishnah / Tanakh)" },
+ { value: "many_to_one", label: "many_to_one" },
+ { value: "one_to_one_default_only", label: "one_to_one_default_only" },
+ { value: "one_to_one", label: "one_to_one" }
+];
From 226d8c66660e28fb3d66e5d711fe048236a88271 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Thu, 8 Jan 2026 14:39:58 +0200
Subject: [PATCH 08/26] feat(modtools): Add BulkVersionEditor component
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add BulkVersionEditor for bulk editing Version metadata
- Supports editing license, status, priority, notes, and other fields
- Integration with IndexSelector for version selection
- Export from modtools module and render in main panel
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
static/js/ModeratorToolsPanel.jsx | 9 +-
.../modtools/components/BulkVersionEditor.jsx | 728 ++++++++++++++++++
static/js/modtools/index.js | 4 +-
3 files changed, 739 insertions(+), 2 deletions(-)
create mode 100644 static/js/modtools/components/BulkVersionEditor.jsx
diff --git a/static/js/ModeratorToolsPanel.jsx b/static/js/ModeratorToolsPanel.jsx
index 9411719b7b..179283ef7d 100644
--- a/static/js/ModeratorToolsPanel.jsx
+++ b/static/js/ModeratorToolsPanel.jsx
@@ -8,6 +8,7 @@
* - CSV upload of texts
* - Workflowy OPML outline upload
* - Links management (upload/download/remove)
+ * - Bulk editing of Version metadata
*
* NOTE: The following tools are temporarily disabled (open tickets to reintroduce):
* - Bulk editing of Index metadata (BulkIndexEditor)
@@ -31,6 +32,7 @@ import WorkflowyModeratorTool from './modtools/components/WorkflowyModeratorTool
import UploadLinksFromCSV from './modtools/components/UploadLinksFromCSV';
import DownloadLinks from './modtools/components/DownloadLinks';
import RemoveLinksFromCsv from './modtools/components/RemoveLinksFromCsv';
+import BulkVersionEditor from './modtools/components/BulkVersionEditor';
// TODO: The following tools are temporarily disabled. There are open tickets to reintroduce them:
// - BulkIndexEditor: Bulk edit index metadata
@@ -48,6 +50,9 @@ import RemoveLinksFromCsv from './modtools/components/RemoveLinksFromCsv';
* Tools are organized in logical order:
* 1. Download/Upload (bulk operations)
* 2. Links management
+ * 3. Bulk editing (Index, Version)
+ * 4. Commentary tools
+ * 5. Schema tools
*/
function ModeratorToolsPanel() {
// Check moderator access
@@ -75,8 +80,10 @@ function ModeratorToolsPanel() {
- {/* Bulk Editing Tools - temporarily disabled, open ticket to reintroduce */}
+ {/* Bulk Editing Tools */}
+ {/* TODO: BulkIndexEditor temporarily disabled - open ticket to reintroduce */}
{/* */}
+
{/* Commentary Tools - temporarily disabled, open ticket to reintroduce */}
{/* */}
diff --git a/static/js/modtools/components/BulkVersionEditor.jsx b/static/js/modtools/components/BulkVersionEditor.jsx
new file mode 100644
index 0000000000..9f300042f2
--- /dev/null
+++ b/static/js/modtools/components/BulkVersionEditor.jsx
@@ -0,0 +1,728 @@
+/**
+ * BulkVersionEditor - Bulk edit Version metadata across multiple indices
+ *
+ * Workflow:
+ * 1. User enters a versionTitle (e.g., "Kehati") and optionally filters by language
+ * 2. Component loads all indices that have versions matching that versionTitle
+ * 3. User selects which indices to update
+ * 4. User fills in fields to change
+ * 5. On save, bulk API updates all selected versions
+ *
+ * Backend API: POST /api/version-bulk-edit
+ * - Returns detailed success/failure info for partial success handling
+ * - See sefaria/views.py version_bulk_edit_api()
+ *
+ * Documentation:
+ * - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
+ * - See /docs/modtools/COMPONENT_LOGIC.md for detailed implementation logic
+ * - Version fields are defined in ../constants/fieldMetadata.js
+ */
+import React, { useState, useCallback } from 'react';
+import Sefaria from '../../sefaria/sefaria';
+import { VERSION_FIELD_METADATA } from '../constants/fieldMetadata';
+import ModToolsSection from './shared/ModToolsSection';
+import IndexSelector from './shared/IndexSelector';
+import StatusMessage, { MESSAGE_TYPES } from './shared/StatusMessage';
+
+/**
+ * Detailed help documentation for this tool
+ */
+const HELP_CONTENT = (
+ <>
+ What This Tool Does
+
+ This tool edits Version metadata across multiple texts simultaneously.
+ A "Version" in Sefaria represents a specific translation or edition of a text
+ (e.g., "Kehati" commentary on Mishnah, or "JPS 1917" translation of Tanakh).
+
+
+ Use this tool when you need to update the same metadata fields across many versions
+ that share a common version title. For example, updating the license information
+ for all "Kehati" versions, or adding source URLs for all "Torat Emet 357" texts.
+
+
+ How It Works
+
+ Search: Enter the exact version title (case-sensitive) to find all texts with matching versions.
+ Select: Choose which texts to update. All are selected by default.
+ Edit: Fill in only the fields you want to change. Empty fields are ignored.
+ Save: Click "Update" to apply changes to all selected versions.
+
+
+ Available Fields
+
+ Note: The version title you searched for is used to identify which versions to update.
+ To rename a version, edit it individually (not in bulk).
+
+
+
+ Field Description
+
+
+ versionTitleInHebrewHebrew version of the title for Hebrew interface
+ versionSourceURL where the original text was sourced from (must be valid URL)
+ licenseCopyright/license type (e.g., "Public Domain", "CC-BY")
+ status"locked" prevents non-staff from editing; empty/unset allows edits
+ priorityDisplay priority (higher = shown first). Use decimal values like 1.5
+ digitizedBySefariaWhether Sefaria digitized this version (true/false)
+ isPrimaryWhether this is the primary version for this language (true/false)
+ isSourceWhether this is a source text, not a translation (true/false)
+ directionText direction: "rtl" or "ltr"
+ versionNotesEnglish notes about this version (shown to users)
+ versionNotesInHebrewHebrew notes about this version
+ purchaseInformationURLLink to purchase the physical book
+ purchaseInformationImageImage URL for the purchase link
+
+
+
+ Clearing Fields
+
+ Each field has a "Clear this field" checkbox below it.
+ When checked, that field will be completely removed from all selected versions (not set to empty string).
+
+
+ Use this when: You want to remove a field entirely from multiple versions.
+ For example, removing outdated purchaseInformationURL links from all versions in a series.
+
+
+ Note: When a field is marked for clearing, its input is disabled and any value you entered is ignored.
+ The field will be deleted from the database, not set to an empty value.
+
+
+ Mark for Deletion
+
+ The "Mark for Deletion" button does NOT immediately delete versions. Instead, it adds
+ a timestamped note to versionNotes flagging the version for manual review.
+ This is a safety mechanism to prevent accidental data loss.
+
+
+
+
Important Notes:
+
+ Version titles are case-sensitive . "Kehati" and "kehati" are different.
+ URL fields are validated. Invalid URLs will prevent saving.
+ Clearing a field removes it entirely from the database (not set to empty string).
+ Setting status: "locked" prevents non-staff users from editing the version.
+ Changes are applied immediately to production data. There is no undo.
+
+
+
+ Common Use Cases
+
+ Adding license information to a publisher's versions
+ Setting source URLs for versions missing attribution
+ Marking outdated versions for review before deletion
+ Updating priority to control which version displays first
+ Adding purchase links for commercially available texts
+
+ >
+);
+
+/**
+ * Field groupings for logical organization in the UI
+ */
+const FIELD_GROUPS = [
+ {
+ id: 'identification',
+ header: 'Version Identification',
+ fields: ['versionTitleInHebrew'] // versionTitle is the search key, not editable
+ },
+ {
+ id: 'source',
+ header: 'Source & License',
+ fields: ['versionSource', 'license', 'purchaseInformationURL', 'purchaseInformationImage']
+ },
+ {
+ id: 'metadata',
+ header: 'Metadata',
+ fields: ['status', 'priority', 'digitizedBySefaria', 'isPrimary', 'isSource', 'direction']
+ },
+ {
+ id: 'notes',
+ header: 'Notes',
+ fields: ['versionNotes', 'versionNotesInHebrew']
+ }
+];
+
+/**
+ * URL validation helper
+ * Validates URL format using native URL constructor
+ * Prevents invalid URLs from being saved to database
+ */
+const isValidUrl = (string) => {
+ if (!string) return true; // Empty is valid (not required)
+ try {
+ new URL(string);
+ return true;
+ } catch (_) {
+ return false;
+ }
+};
+
+/**
+ * Fields that store URLs and require validation
+ * These become clickable links in the UI, so we validate format on input
+ */
+const URL_FIELDS = ['versionSource', 'purchaseInformationURL', 'purchaseInformationImage'];
+
+/**
+ * Validate a field value and return error message if invalid
+ * Only validates URL fields - returns null for all other fields
+ */
+const getFieldValidationError = (field, value) => {
+ const isUrlField = URL_FIELDS.includes(field);
+ if (isUrlField && value && !isValidUrl(value)) {
+ return "Please enter a valid URL (e.g., https://example.com)";
+ }
+ return null;
+};
+
+const BulkVersionEditor = () => {
+ // Search state
+ const [vtitle, setVtitle] = useState("");
+ const [lang, setLang] = useState("");
+ const [searched, setSearched] = useState(false);
+
+ // Results state
+ // indices: Array of {title: string, categories?: string[]} objects
+ const [indices, setIndices] = useState([]);
+ const [pick, setPick] = useState(new Set());
+
+ // Edit state
+ const [updates, setUpdates] = useState({});
+ const [validationErrors, setValidationErrors] = useState({});
+ const [fieldsToClear, setFieldsToClear] = useState(new Set());
+
+ // UI state
+ const [msg, setMsg] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+ /**
+ * Clear search and reset state
+ */
+ const clearSearch = useCallback(() => {
+ setIndices([]);
+ setPick(new Set());
+ setUpdates({});
+ setValidationErrors({});
+ setFieldsToClear(new Set());
+ setMsg("");
+ setSearched(false);
+ }, []);
+
+ /**
+ * Load indices that have versions matching the search criteria
+ */
+ const load = async () => {
+ if (!vtitle.trim()) {
+ setMsg({ type: MESSAGE_TYPES.WARNING, message: 'Please enter a version title' });
+ return;
+ }
+
+ setLoading(true);
+ setSearched(true);
+ setMsg({ type: MESSAGE_TYPES.INFO, message: 'Loading indices...' });
+
+ const urlParams = { versionTitle: vtitle };
+ if (lang) {
+ urlParams.language = lang;
+ }
+
+ try {
+ const data = await Sefaria.apiRequestWithBody('/api/version-indices', urlParams, null, 'GET');
+ const resultIndices = data.indices || [];
+ const resultMetadata = data.metadata || {};
+ // Combine indices and metadata into single array of objects
+ const combinedIndices = resultIndices.map(title => ({
+ title,
+ categories: resultMetadata[title]?.categories
+ }));
+ setIndices(combinedIndices);
+ setPick(new Set(resultIndices)); // Pre-select all (Set of title strings)
+ setMsg(""); // Clear loading message - result count shown in IndexSelector header
+ } catch (error) {
+ setMsg({ type: MESSAGE_TYPES.ERROR, message: `Error: ${error.message || "Failed to load indices"}` });
+ setIndices([]);
+ setPick(new Set());
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ /**
+ * Handle field value changes with validation
+ * Stores user input and validates URL fields in real-time
+ */
+ const handleFieldChange = useCallback((field, value) => {
+ // Store the field value (remove if empty)
+ setUpdates(prev => {
+ const next = { ...prev };
+ if (value) {
+ next[field] = value;
+ } else {
+ delete next[field];
+ }
+ return next;
+ });
+
+ // Validate and update error state
+ const errorMessage = getFieldValidationError(field, value);
+ setValidationErrors(prev => {
+ const next = { ...prev };
+ if (errorMessage) {
+ next[field] = errorMessage;
+ } else {
+ delete next[field];
+ }
+ return next;
+ });
+ }, []);
+
+ /**
+ * Handle clear checkbox toggle for a field
+ * When checked, field will be cleared (removed) from all selected versions
+ */
+ const handleClearToggle = useCallback((field, checked) => {
+ setFieldsToClear(prev => {
+ const next = new Set(prev);
+ if (checked) {
+ next.add(field);
+ } else {
+ next.delete(field);
+ }
+ return next;
+ });
+
+ // When clearing, remove any pending updates for this field
+ if (checked) {
+ setUpdates(prev => {
+ const next = { ...prev };
+ delete next[field];
+ return next;
+ });
+ setValidationErrors(prev => {
+ const next = { ...prev };
+ delete next[field];
+ return next;
+ });
+ }
+ }, []);
+
+ /**
+ * Perform bulk edit API call and handle response
+ * @param {Object} updatesToApply - The updates object to send to the API
+ * @param {Function} getSuccessMsg - Function that takes successCount and returns success message
+ * @param {Function} getPartialMsg - Function that takes successCount, total, failureList and returns partial success message
+ * @param {Function} getErrorMsg - Function that takes failureCount, failureList and returns error message
+ * @param {Function} onSuccess - Optional callback to run on successful completion
+ */
+ const performBulkEdit = async (updatesToApply, getSuccessMsg, getPartialMsg, getErrorMsg, onSuccess) => {
+ setSaving(true);
+
+ try {
+ const payload = {
+ versionTitle: vtitle,
+ indices: Array.from(pick),
+ updates: updatesToApply
+ };
+
+ const data = await Sefaria.apiRequestWithBody('/api/version-bulk-edit', null, payload);
+ const successCount = data.successes?.length || 0;
+ const failureCount = data.failures?.length || 0;
+ const total = successCount + failureCount;
+
+ if (data.status === "ok") {
+ setMsg({ type: MESSAGE_TYPES.SUCCESS, message: getSuccessMsg(successCount) });
+ if (onSuccess) onSuccess();
+ } else if (data.status === "partial") {
+ const failureList = data.failures.map(f => `• ${f.index}: ${f.error}`).join("\n");
+ setMsg({ type: MESSAGE_TYPES.WARNING, message: getPartialMsg(successCount, total, failureList) });
+ } else {
+ const failureList = data.failures?.map(f => `• ${f.index}: ${f.error}`).join("\n") || "Unknown error";
+ setMsg({ type: MESSAGE_TYPES.ERROR, message: getErrorMsg(failureCount, failureList) });
+ }
+ } catch (error) {
+ const errorMsg = error.message || "Unknown error";
+ setMsg({ type: MESSAGE_TYPES.ERROR, message: `Error: ${errorMsg}` });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ /**
+ * Save changes to selected versions
+ */
+ const save = async () => {
+ // Validation is handled proactively via getValidationState()
+ // Button is disabled when validation fails, so these are just safety checks
+ if (!pick.size || !hasChanges || hasValidationErrors) {
+ return;
+ }
+
+ setMsg({ type: MESSAGE_TYPES.INFO, message: 'Saving changes...' });
+
+ // Convert boolean string values to actual booleans for the API
+ const processedUpdates = { ...updates };
+ ['digitizedBySefaria', 'isPrimary', 'isSource'].forEach(field => {
+ if (processedUpdates[field] === 'true') {
+ processedUpdates[field] = true;
+ } else if (processedUpdates[field] === 'false') {
+ processedUpdates[field] = false;
+ }
+ });
+
+ // Add cleared fields with null value (backend will delete them)
+ fieldsToClear.forEach(field => {
+ processedUpdates[field] = null;
+ });
+
+ await performBulkEdit(
+ processedUpdates,
+ (successCount) => `Successfully updated ${successCount} versions`,
+ (successCount, total, failureList) => `Updated ${successCount}/${total} versions.\n\nFailed:\n${failureList}`,
+ (failureCount, failureList) => `All ${failureCount} updates failed:\n${failureList}`,
+ () => {
+ setUpdates({});
+ setValidationErrors({});
+ setFieldsToClear(new Set());
+ }
+ );
+ };
+
+ /**
+ * Mark selected versions for deletion (soft delete)
+ * Adds a note to versionNotes marking them for review
+ */
+ const markForDeletion = async () => {
+ // Button only shows when pick.size > 0, but safety check anyway
+ if (!pick.size) return;
+
+ setShowDeleteConfirm(false);
+ setMsg({ type: MESSAGE_TYPES.INFO, message: 'Marking versions for deletion review...' });
+
+ const deletionNote = `[MARKED FOR DELETION - ${new Date().toISOString().split('T')[0]}] This version has been marked for deletion review.`;
+
+ await performBulkEdit(
+ { versionNotes: deletionNote },
+ (successCount) => `Marked ${successCount} versions for deletion review. They can be found by searching for "[MARKED FOR DELETION" in version notes.`,
+ (successCount, total, failureList) => `Marked ${successCount}/${total} versions.\n\nFailed:\n${failureList}`,
+ (failureCount, failureList) => `All ${failureCount} versions failed to be marked for deletion:\n${failureList}`
+ );
+ };
+
+ /**
+ * Render a field input based on its metadata
+ * All fields in FIELD_GROUPS are guaranteed to have metadata in VERSION_FIELD_METADATA
+ */
+ const renderField = (fieldName) => {
+ const meta = VERSION_FIELD_METADATA[fieldName];
+ const value = updates[fieldName] || "";
+ const error = validationErrors[fieldName];
+ const hasError = !!error;
+ const isClearing = fieldsToClear.has(fieldName);
+
+ return (
+
+
+ {meta.label}:
+
+
+ {meta.help && (
+
{meta.help}
+ )}
+
+ {meta.type === "select" && meta.options ? (
+
handleFieldChange(fieldName, e.target.value)}
+ style={{ direction: meta.dir || "ltr" }}
+ disabled={isClearing}
+ >
+ {meta.options.map(opt => (
+ {opt.label}
+ ))}
+
+ ) : meta.type === "textarea" ? (
+
+ );
+ };
+
+ const hasChanges = Object.keys(updates).length > 0 || fieldsToClear.size > 0;
+ const hasValidationErrors = Object.keys(validationErrors).length > 0;
+
+ // Compute validation state message (shown proactively, not on click)
+ const getValidationState = () => {
+ // Don't show validation messages while saving or if there's an active result message
+ if (saving) return null;
+ if (msg && (msg.type === MESSAGE_TYPES.SUCCESS || msg.type === MESSAGE_TYPES.ERROR)) return msg;
+
+ // Show validation warnings proactively
+ if (indices.length > 0 && pick.size === 0) {
+ return { type: MESSAGE_TYPES.WARNING, message: 'No indices selected' };
+ }
+ if (pick.size > 0 && !hasChanges) {
+ return { type: MESSAGE_TYPES.WARNING, message: 'No fields to update or clear' };
+ }
+ if (hasValidationErrors) {
+ return { type: MESSAGE_TYPES.WARNING, message: 'Please fix validation errors before saving' };
+ }
+
+ return msg; // Show any other message (like success/error from API)
+ };
+
+ const currentMessage = getValidationState();
+ const isButtonDisabled = saving || hasValidationErrors ||
+ (currentMessage?.type === MESSAGE_TYPES.WARNING);
+
+ return (
+
+ {/* Info box */}
+
+ How it works: Enter a version title to find all texts with matching versions.
+ Select which texts to update, fill in the fields you want to change, then save.
+ Only filled-in fields will be modified.
+
+
+ {/* Search description */}
+
+ Enter the exact version title as it appears in the database. The search is case-sensitive.
+
+
+ {/* Search bar - input + button inline */}
+
+ setVtitle(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && load()}
+ />
+
+ {loading ? <> Searching...> : "Search"}
+
+
+
+ {/* Language filter - inline row */}
+
+ Filter by language:
+ setLang(e.target.value)}
+ >
+ All languages
+ Hebrew only
+ English only
+
+
+
+ {/* Clear button - centered */}
+ {searched && (
+
+
+ Clear Search
+
+
+ )}
+
+ {/* No results message */}
+ {searched && !loading && indices.length === 0 && (
+
+ No texts found with version "{vtitle}"
+ Please verify the exact version title. Version titles are case-sensitive
+ and must match exactly (e.g., "Torat Emet 357" not "torat emet").
+
+ )}
+
+ {/* Index selector */}
+ {indices.length > 0 && (
+
+ )}
+
+ {/* Field inputs grouped by section */}
+ {pick.size > 0 && (
+ <>
+
+ Edit fields for {pick.size} selected {pick.size === 1 ? 'text' : 'texts'}:
+
+
+ {FIELD_GROUPS.map(group => (
+
+
{group.header}
+
+ {group.fields.map(fieldName => renderField(fieldName))}
+
+
+ ))}
+
+ {/* Changes preview */}
+ {hasChanges && (
+
+
Changes to apply:
+
+ {Object.entries(updates).map(([k, v]) => (
+
+ {VERSION_FIELD_METADATA[k]?.label || k}: "{v}"
+
+ ))}
+ {Array.from(fieldsToClear).map(field => (
+
+ {VERSION_FIELD_METADATA[field]?.label || field}: (clear)
+
+ ))}
+
+
+ )}
+
+ {/* Validation warning */}
+ {hasValidationErrors && (
+
+
Please fix validation errors before saving:
+
+ {Object.entries(validationErrors).map(([field, error]) => (
+ {VERSION_FIELD_METADATA[field]?.label || field}: {error}
+ ))}
+
+
+ )}
+
+ {/* Status message - shows validation feedback, success/error results */}
+
+
+ {/* Save button */}
+
+
+ {saving ? (
+ <> Saving...>
+ ) : (
+ `Save Changes`
+ )}
+
+
+
+ {/* Delete section - separated at bottom */}
+
+
+ {/* Delete confirmation dialog */}
+ {showDeleteConfirm && (
+
+
Confirm Mark for Deletion
+
+ This will mark {pick.size} versions for deletion review by adding a note to their versionNotes field.
+ The versions will not be immediately deleted - they will be flagged for manual review.
+
+
+ Affected texts: {Array.from(pick).slice(0, 5).join(", ")}
+ {pick.size > 5 && ` and ${pick.size - 5} more...`}
+
+
+
+ {saving ? <> Processing...> : "Yes, Mark for Deletion"}
+
+ setShowDeleteConfirm(false)}
+ disabled={saving}
+ >
+ Cancel
+
+
+
+ )}
+
+ {/* Delete button - at very bottom */}
+ {!showDeleteConfirm && (
+
+ setShowDeleteConfirm(true)}
+ disabled={saving}
+ type="button"
+ >
+ Mark for Deletion
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default BulkVersionEditor;
diff --git a/static/js/modtools/index.js b/static/js/modtools/index.js
index 73348fa1c1..f84fcd116a 100644
--- a/static/js/modtools/index.js
+++ b/static/js/modtools/index.js
@@ -12,11 +12,13 @@ export { default as WorkflowyModeratorTool } from './components/WorkflowyModerat
export { default as UploadLinksFromCSV } from './components/UploadLinksFromCSV';
export { default as DownloadLinks } from './components/DownloadLinks';
export { default as RemoveLinksFromCsv } from './components/RemoveLinksFromCsv';
+export { default as BulkVersionEditor } from './components/BulkVersionEditor';
// Shared UI components
export {
ModToolsSection,
HelpButton,
StatusMessage,
- MESSAGE_TYPES
+ MESSAGE_TYPES,
+ IndexSelector
} from './components/shared';
From 585587b699e6e1efaaff0b37ecd223080ed1b032 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Thu, 8 Jan 2026 14:40:04 +0200
Subject: [PATCH 09/26] test(modtools): Add API and frontend tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add modtools_test.py for version bulk edit API testing
- Add fieldMetadata.test.js for field metadata validation
- Add stripHtmlTags.test.js for HTML utility function tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
sefaria/tests/modtools_test.py | 569 ++++++++++++++++++
.../js/modtools/tests/fieldMetadata.test.js | 201 +++++++
.../js/modtools/tests/stripHtmlTags.test.js | 108 ++++
3 files changed, 878 insertions(+)
create mode 100644 sefaria/tests/modtools_test.py
create mode 100644 static/js/modtools/tests/fieldMetadata.test.js
create mode 100644 static/js/modtools/tests/stripHtmlTags.test.js
diff --git a/sefaria/tests/modtools_test.py b/sefaria/tests/modtools_test.py
new file mode 100644
index 0000000000..7edbec6616
--- /dev/null
+++ b/sefaria/tests/modtools_test.py
@@ -0,0 +1,569 @@
+"""
+Tests for ModeratorToolsPanel API endpoints.
+
+These tests cover the bulk editing functionality for version metadata.
+"""
+import pytest
+import json
+from django.test import Client
+from django.contrib.auth.models import User
+
+
+@pytest.fixture
+def staff_client(db):
+ """Create a staff user and return an authenticated client."""
+ user = User.objects.create_user(
+ username='teststaff',
+ email='test@sefaria.org',
+ password='testpass123',
+ is_staff=True
+ )
+ client = Client()
+ client.force_login(user) # Use force_login instead of login
+ return client
+
+
+@pytest.fixture
+def regular_client(db):
+ """Create a regular (non-staff) user and return an authenticated client."""
+ user = User.objects.create_user(
+ username='testuser',
+ email='user@sefaria.org',
+ password='testpass123',
+ is_staff=False
+ )
+ client = Client()
+ client.force_login(user)
+ return client
+
+
+@pytest.fixture
+def anon_client():
+ """Return an unauthenticated client."""
+ return Client()
+
+
+class TestVersionIndicesAPI:
+ """Tests for /api/version-indices endpoint."""
+
+ @pytest.mark.django_db
+ def test_version_indices_requires_auth(self, anon_client):
+ """Unauthenticated users should be redirected."""
+ response = anon_client.get('/api/version-indices')
+ assert response.status_code == 302 # Redirect to login
+
+ @pytest.mark.django_db
+ def test_version_indices_returns_list(self, staff_client):
+ """Should return list of indices for valid versionTitle."""
+ response = staff_client.get('/api/version-indices', {
+ 'versionTitle': 'Tanach with Nikkud'
+ })
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert 'indices' in data
+ assert isinstance(data['indices'], list)
+
+ @pytest.mark.django_db
+ def test_version_indices_empty_for_nonexistent(self, staff_client):
+ """Should return empty list for nonexistent versionTitle."""
+ response = staff_client.get('/api/version-indices', {
+ 'versionTitle': 'NonexistentVersion99999'
+ })
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert 'indices' in data
+ assert data['indices'] == []
+
+
+class TestVersionBulkEditAPI:
+ """Tests for /api/version-bulk-edit endpoint."""
+
+ @pytest.mark.django_db
+ def test_bulk_edit_requires_staff(self, regular_client):
+ """Non-staff users should be denied access."""
+ response = regular_client.post(
+ '/api/version-bulk-edit',
+ data=json.dumps({
+ 'versionTitle': 'Test',
+ 'language': 'en',
+ 'indices': ['Genesis'],
+ 'updates': {'license': 'CC-BY'}
+ }),
+ content_type='application/json'
+ )
+ # Should redirect to login or return 403
+ assert response.status_code in [302, 403]
+
+ @pytest.mark.django_db
+ def test_bulk_edit_requires_post(self, staff_client):
+ """GET requests should be rejected."""
+ response = staff_client.get('/api/version-bulk-edit')
+ assert response.status_code == 400
+
+ @pytest.mark.django_db
+ def test_bulk_edit_requires_indices(self, staff_client):
+ """Should error when indices array is empty."""
+ response = staff_client.post(
+ '/api/version-bulk-edit',
+ data=json.dumps({
+ 'versionTitle': 'Test',
+ 'language': 'en',
+ 'indices': [],
+ 'updates': {'license': 'CC-BY'}
+ }),
+ content_type='application/json'
+ )
+ # Empty indices should return 400 (bad request) with error message
+ assert response.status_code == 400
+ data = json.loads(response.content)
+ assert 'error' in data
+ assert 'empty' in data['error'].lower()
+
+ @pytest.mark.django_db
+ def test_bulk_edit_returns_detailed_response(self, staff_client):
+ """Should return status, successes, failures."""
+ response = staff_client.post(
+ '/api/version-bulk-edit',
+ data=json.dumps({
+ 'versionTitle': 'NonexistentVersion12345',
+ 'language': 'en',
+ 'indices': ['Genesis'],
+ 'updates': {'license': 'CC-BY'}
+ }),
+ content_type='application/json'
+ )
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ # Should have the detailed response format
+ assert 'status' in data
+ assert 'successes' in data
+ assert 'failures' in data
+ # Version doesn't exist, so should report failure
+ assert data['status'] in ['error', 'partial']
+ assert len(data['failures']) > 0
+
+ @pytest.mark.django_db
+ def test_bulk_edit_null_clears_field(self, staff_client):
+ """Sending null value should remove field from version entirely."""
+ from sefaria.model import VersionSet, Version
+
+ # Create a test version with purchaseInformationURL set
+ test_version = Version({
+ 'versionTitle': 'TestVersionForClearing',
+ 'language': 'en',
+ 'title': 'Genesis',
+ 'chapter': [],
+ 'versionSource': 'https://test.com',
+ 'purchaseInformationURL': 'https://example.com/buy'
+ })
+ test_version.save()
+
+ # Verify field exists before clearing
+ v = Version().load({'versionTitle': 'TestVersionForClearing', 'language': 'en', 'title': 'Genesis'})
+ assert hasattr(v, 'purchaseInformationURL'), "Field should exist before clearing"
+ assert v.purchaseInformationURL == 'https://example.com/buy'
+
+ # Send null value to clear the field
+ response = staff_client.post(
+ '/api/version-bulk-edit',
+ data=json.dumps({
+ 'versionTitle': 'TestVersionForClearing',
+ 'language': 'en',
+ 'indices': ['Genesis'],
+ 'updates': {'purchaseInformationURL': None}
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert data['status'] == 'ok'
+ assert len(data['successes']) == 1
+
+ # Verify field was removed (not just set to null or empty string)
+ v = Version().load({'versionTitle': 'TestVersionForClearing', 'language': 'en', 'title': 'Genesis'})
+ assert not hasattr(v, 'purchaseInformationURL'), "Field should be completely removed after clearing"
+
+ # Cleanup
+ v.delete()
+
+ @pytest.mark.django_db
+ def test_bulk_edit_mixed_updates_and_clears(self, staff_client):
+ """Should handle both field updates and field clears in same request."""
+ from sefaria.model import VersionSet, Version
+
+ # Create a test version with multiple fields
+ test_version = Version({
+ 'versionTitle': 'TestVersionMixed',
+ 'language': 'en',
+ 'title': 'Genesis',
+ 'chapter': [],
+ 'versionSource': 'https://test.com',
+ 'license': 'PD',
+ 'purchaseInformationURL': 'https://example.com/buy',
+ 'versionNotes': 'Old notes'
+ })
+ test_version.save()
+
+ # Update some fields, clear others
+ response = staff_client.post(
+ '/api/version-bulk-edit',
+ data=json.dumps({
+ 'versionTitle': 'TestVersionMixed',
+ 'language': 'en',
+ 'indices': ['Genesis'],
+ 'updates': {
+ 'license': 'CC-BY', # Update this field
+ 'purchaseInformationURL': None, # Clear this field
+ 'versionNotes': 'New notes' # Update this field
+ }
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert data['status'] == 'ok'
+
+ # Verify updates applied and field cleared
+ v = Version().load({'versionTitle': 'TestVersionMixed', 'language': 'en', 'title': 'Genesis'})
+ assert v.license == 'CC-BY', "License should be updated"
+ assert v.versionNotes == 'New notes', "Notes should be updated"
+ assert not hasattr(v, 'purchaseInformationURL'), "purchaseInformationURL should be removed"
+
+ # Cleanup
+ v.delete()
+
+ @pytest.mark.django_db
+ def test_bulk_edit_clear_nonexistent_field(self, staff_client):
+ """Clearing a field that doesn't exist should not error."""
+ from sefaria.model import VersionSet, Version
+
+ # Create a test version without purchaseInformationURL
+ test_version = Version({
+ 'versionTitle': 'TestVersionNoField',
+ 'language': 'en',
+ 'title': 'Genesis',
+ 'chapter': [],
+ 'versionSource': 'https://test.com'
+ })
+ test_version.save()
+
+ # Try to clear a field that doesn't exist
+ response = staff_client.post(
+ '/api/version-bulk-edit',
+ data=json.dumps({
+ 'versionTitle': 'TestVersionNoField',
+ 'language': 'en',
+ 'indices': ['Genesis'],
+ 'updates': {'purchaseInformationURL': None}
+ }),
+ content_type='application/json'
+ )
+
+ # Should succeed without error
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert data['status'] == 'ok'
+
+ # Verify version still exists and wasn't broken
+ v = Version().load({'versionTitle': 'TestVersionNoField', 'language': 'en', 'title': 'Genesis'})
+ assert v is not None
+ assert not hasattr(v, 'purchaseInformationURL')
+
+ # Cleanup
+ v.delete()
+
+ @pytest.mark.django_db
+ def test_bulk_edit_without_language_parameter(self, staff_client):
+ """Language parameter should be optional since title+versionTitle is unique.
+
+ When user doesn't select a language filter in the UI, the frontend
+ doesn't send a language parameter. The API should still find and
+ update the version using just title + versionTitle.
+ """
+ from sefaria.model import Version
+
+ # Create a test version with Hebrew language
+ test_version = Version({
+ 'versionTitle': 'TestVersionNoLang',
+ 'language': 'he',
+ 'title': 'Genesis',
+ 'chapter': [],
+ 'versionSource': 'https://test.com',
+ 'versionNotes': 'Original notes'
+ })
+ test_version.save()
+
+ # Request without language parameter (as frontend now sends)
+ response = staff_client.post(
+ '/api/version-bulk-edit',
+ data=json.dumps({
+ 'versionTitle': 'TestVersionNoLang',
+ # No language parameter
+ 'indices': ['Genesis'],
+ 'updates': {'versionNotes': 'Updated notes'}
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = json.loads(response.content)
+
+ # Should succeed - title + versionTitle is sufficient to find the version
+ assert data['status'] == 'ok', f"Expected success, got: {data}"
+ assert len(data['successes']) == 1
+ assert len(data['failures']) == 0
+
+ # Verify the update was applied
+ v = Version().load({'versionTitle': 'TestVersionNoLang', 'title': 'Genesis'})
+ assert v is not None
+ assert v.versionNotes == 'Updated notes'
+
+ # Cleanup
+ v.delete()
+
+
+class TestCheckIndexDependenciesAPI:
+ """Tests for /api/check-index-dependencies endpoint."""
+
+ @pytest.mark.django_db
+ def test_check_dependencies_requires_staff(self, regular_client):
+ """Non-staff users should be denied access."""
+ response = regular_client.get('/api/check-index-dependencies/Genesis')
+ assert response.status_code in [302, 403]
+
+ @pytest.mark.django_db
+ def test_check_dependencies_returns_info(self, staff_client):
+ """Should return dependency information for valid index."""
+ response = staff_client.get('/api/check-index-dependencies/Genesis')
+ assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.content}"
+ data = json.loads(response.content)
+ assert 'has_dependencies' in data, "Response missing 'has_dependencies' field"
+ assert 'dependent_indices' in data, "Response missing 'dependent_indices' field"
+
+
+# ============================================================================
+# Legacy Modtools API Tests (Priority 1 - Write Operations)
+# ============================================================================
+
+from io import BytesIO
+from django.core.files.uploadedfile import SimpleUploadedFile
+
+
+@pytest.fixture
+def sample_links_csv():
+ """Create a sample CSV file for links upload testing."""
+ csv_content = b"Genesis 1:1,Rashi on Genesis 1:1\nGenesis 1:2,Rashi on Genesis 1:2"
+ return SimpleUploadedFile("links.csv", csv_content, content_type="text/csv")
+
+
+@pytest.fixture
+def malformed_csv():
+ """Create a malformed CSV file for error testing."""
+ csv_content = b"InvalidRef,AnotherInvalidRef"
+ return SimpleUploadedFile("bad.csv", csv_content, content_type="text/csv")
+
+
+class TestLinksUploadAPI:
+ """Tests for /modtools/links endpoint (POST for upload)."""
+
+ @pytest.mark.django_db
+ def test_links_upload_requires_staff(self, regular_client, sample_links_csv):
+ """Non-staff users should be denied access."""
+ response = regular_client.post('/modtools/links', {
+ 'csv_file': sample_links_csv,
+ 'linkType': 'commentary',
+ 'projectName': 'Test'
+ })
+ # Should redirect to login
+ assert response.status_code == 302
+
+ @pytest.mark.django_db
+ def test_links_upload_requires_auth(self, anon_client, sample_links_csv):
+ """Unauthenticated users should be redirected."""
+ response = anon_client.post('/modtools/links', {
+ 'csv_file': sample_links_csv,
+ 'linkType': 'commentary',
+ 'projectName': 'Test'
+ })
+ assert response.status_code == 302
+
+ @pytest.mark.django_db
+ def test_links_upload_requires_post(self, staff_client):
+ """GET requests should return error."""
+ response = staff_client.get('/modtools/links')
+ assert response.status_code == 200 # Returns JSON error
+ data = json.loads(response.content)
+ assert 'error' in data
+ assert 'Unsupported Method' in data['error']
+
+ @pytest.mark.django_db
+ def test_links_upload_requires_csv_file(self, staff_client):
+ """Should error when no CSV file provided."""
+ # The endpoint will raise KeyError when csv_file is missing
+ # This is expected behavior - test that it doesn't silently succeed
+ with pytest.raises(KeyError):
+ staff_client.post('/modtools/links', {
+ 'linkType': 'commentary',
+ 'projectName': 'Test'
+ })
+
+
+class TestLinksDeleteAPI:
+ """Tests for /modtools/links endpoint (POST with action=DELETE)."""
+
+ @pytest.mark.django_db
+ def test_links_delete_requires_staff(self, regular_client, sample_links_csv):
+ """Non-staff users should be denied access for delete."""
+ response = regular_client.post('/modtools/links', {
+ 'csv_file': sample_links_csv,
+ 'action': 'DELETE'
+ })
+ assert response.status_code == 302
+
+ @pytest.mark.django_db
+ def test_links_delete_uses_delete_action(self, staff_client, sample_links_csv):
+ """DELETE action should call remove_links_from_csv."""
+ response = staff_client.post('/modtools/links', {
+ 'csv_file': sample_links_csv,
+ 'action': 'DELETE'
+ })
+ # Should process (may fail on invalid refs, but should not error on endpoint)
+ assert response.status_code in [200, 400]
+
+
+class TestTextUploadAPI:
+ """Tests for /api/text-upload endpoint."""
+
+ @pytest.mark.django_db
+ def test_text_upload_requires_staff(self, regular_client):
+ """Non-staff users should be denied access."""
+ fake_text = SimpleUploadedFile("test.json", b'{"test": "data"}', content_type="application/json")
+ response = regular_client.post('/api/text-upload', {
+ 'texts[]': fake_text
+ })
+ # Should redirect or return 403
+ assert response.status_code in [302, 403]
+
+ @pytest.mark.django_db
+ def test_text_upload_requires_post(self, staff_client):
+ """GET requests should return error."""
+ response = staff_client.get('/api/text-upload')
+ # May return 405 or redirect depending on URL routing
+ # The endpoint explicitly checks for POST
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert 'error' in data
+
+
+class TestWorkflowyUploadAPI:
+ """Tests for /modtools/upload_text endpoint."""
+
+ @pytest.fixture
+ def sample_workflowy_xml(self):
+ """Create a sample Workflowy XML file."""
+ xml_content = b"""
+
+ Test
+
+ """
+ return SimpleUploadedFile("workflowy.opml", xml_content, content_type="text/xml")
+
+ @pytest.mark.django_db
+ def test_workflowy_requires_staff(self, regular_client, sample_workflowy_xml):
+ """Non-staff users should be denied access."""
+ response = regular_client.post('/modtools/upload_text', {
+ 'workflowys[]': sample_workflowy_xml
+ })
+ assert response.status_code == 302
+
+ @pytest.mark.django_db
+ def test_workflowy_requires_auth(self, anon_client, sample_workflowy_xml):
+ """Unauthenticated users should be redirected."""
+ response = anon_client.post('/modtools/upload_text', {
+ 'workflowys[]': sample_workflowy_xml
+ })
+ assert response.status_code == 302
+
+ @pytest.mark.django_db
+ def test_workflowy_requires_post(self, staff_client):
+ """GET requests should return error."""
+ response = staff_client.get('/modtools/upload_text')
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert 'error' in data
+
+ @pytest.mark.django_db
+ def test_workflowy_requires_files(self, staff_client):
+ """Should error when no files provided."""
+ response = staff_client.post('/modtools/upload_text', {})
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert 'error' in data
+ assert 'No files' in data['error']
+
+
+# ============================================================================
+# Legacy Modtools API Tests (Priority 2 - Read Operations)
+# ============================================================================
+
+class TestBulkDownloadVersionsAPI:
+ """Tests for /download/bulk/versions/ endpoint."""
+
+ @pytest.mark.django_db
+ def test_bulk_download_requires_staff(self, regular_client):
+ """Non-staff users should be denied access."""
+ response = regular_client.get('/download/bulk/versions/', {
+ 'title': 'Genesis'
+ })
+ assert response.status_code in [302, 403]
+
+ @pytest.mark.django_db
+ def test_bulk_download_requires_title(self, staff_client):
+ """Should error when no title provided."""
+ response = staff_client.get('/download/bulk/versions/')
+ # Should return error for missing title
+ assert response.status_code in [400, 500] or 'error' in response.content.decode()
+
+ @pytest.mark.django_db
+ def test_bulk_download_returns_response(self, staff_client):
+ """Should return CSV or JSON for valid title."""
+ response = staff_client.get('/download/bulk/versions/', {
+ 'title': 'Genesis'
+ })
+ # Should return 200 with either CSV or JSON
+ assert response.status_code == 200
+ content_type = response['Content-Type']
+ # Accept both CSV (success) and JSON (may contain error/empty result)
+ assert 'text/csv' in content_type or 'application/json' in content_type
+
+
+class TestLinksDownloadAPI:
+ """Tests for /modtools/links// endpoint."""
+
+ @pytest.mark.django_db
+ def test_links_download_returns_csv(self, staff_client):
+ """Should return CSV for valid refs."""
+ response = staff_client.get('/modtools/links/Genesis 1/Rashi on Genesis 1')
+ # Should return 200 with CSV content type
+ assert response.status_code == 200, f"Expected 200, got {response.status_code}"
+ assert 'text/csv' in response['Content-Type'], f"Expected CSV, got {response['Content-Type']}"
+
+ @pytest.mark.django_db
+ def test_links_download_handles_invalid_refs(self, staff_client):
+ """Should handle invalid references gracefully."""
+ response = staff_client.get('/modtools/links/InvalidRef123/AnotherInvalidRef')
+ # Should return 400 for invalid refs
+ assert response.status_code in [200, 400]
+
+
+class TestIndexLinksDownloadAPI:
+ """Tests for /modtools/index_links// endpoint."""
+
+ @pytest.mark.django_db
+ def test_index_links_returns_csv(self, staff_client):
+ """Should return CSV for valid refs with by_segment=True."""
+ response = staff_client.get('/modtools/index_links/Genesis 1/Rashi on Genesis 1')
+ assert response.status_code == 200, f"Expected 200, got {response.status_code}"
+ assert 'text/csv' in response['Content-Type'], f"Expected CSV, got {response['Content-Type']}"
diff --git a/static/js/modtools/tests/fieldMetadata.test.js b/static/js/modtools/tests/fieldMetadata.test.js
new file mode 100644
index 0000000000..d3f2e22c05
--- /dev/null
+++ b/static/js/modtools/tests/fieldMetadata.test.js
@@ -0,0 +1,201 @@
+/**
+ * Tests for fieldMetadata constants
+ *
+ * These constants define the schema for bulk editing operations.
+ * Tests ensure the structure is correct and validation functions work.
+ */
+import {
+ INDEX_FIELD_METADATA,
+ VERSION_FIELD_METADATA,
+ BASE_TEXT_MAPPING_OPTIONS
+} from '../constants/fieldMetadata';
+
+describe('INDEX_FIELD_METADATA', () => {
+ describe('structure validation', () => {
+ it('has all expected fields', () => {
+ const expectedFields = [
+ 'enDesc', 'enShortDesc', 'heDesc', 'heShortDesc',
+ 'categories', 'authors', 'compDate', 'compPlace', 'heCompPlace',
+ 'pubDate', 'pubPlace', 'hePubPlace', 'toc_zoom',
+ 'dependence', 'base_text_titles', 'collective_title', 'he_collective_title'
+ ];
+ expectedFields.forEach(field => {
+ expect(INDEX_FIELD_METADATA).toHaveProperty(field);
+ });
+ });
+
+ it('each field has required properties', () => {
+ Object.entries(INDEX_FIELD_METADATA).forEach(([fieldName, meta]) => {
+ expect(meta).toHaveProperty('label');
+ expect(meta).toHaveProperty('type');
+ expect(typeof meta.label).toBe('string');
+ expect(['text', 'textarea', 'select', 'array', 'number', 'daterange']).toContain(meta.type);
+ });
+ });
+
+ it('select fields have options array', () => {
+ Object.entries(INDEX_FIELD_METADATA).forEach(([fieldName, meta]) => {
+ if (meta.type === 'select') {
+ expect(meta.options).toBeDefined();
+ expect(Array.isArray(meta.options)).toBe(true);
+ expect(meta.options.length).toBeGreaterThan(0);
+ }
+ });
+ });
+
+ it('Hebrew fields have rtl direction', () => {
+ const hebrewFields = ['heDesc', 'heShortDesc', 'heCompPlace', 'hePubPlace', 'he_collective_title'];
+ hebrewFields.forEach(field => {
+ expect(INDEX_FIELD_METADATA[field].dir).toBe('rtl');
+ });
+ });
+ });
+
+ describe('toc_zoom validation', () => {
+ const validate = INDEX_FIELD_METADATA.toc_zoom.validate;
+
+ it('accepts empty values', () => {
+ expect(validate('')).toBe(true);
+ expect(validate(null)).toBe(true);
+ expect(validate(undefined)).toBe(true);
+ });
+
+ it('accepts valid integers 0-10', () => {
+ for (let i = 0; i <= 10; i++) {
+ expect(validate(String(i))).toBe(true);
+ }
+ });
+
+ it('rejects integers outside 0-10 range', () => {
+ expect(validate('-1')).toBe(false);
+ expect(validate('11')).toBe(false);
+ expect(validate('100')).toBe(false);
+ });
+
+ it('rejects non-numeric values', () => {
+ expect(validate('abc')).toBe(false);
+ expect(validate('one')).toBe(false);
+ });
+
+ it('accepts decimals (parseInt truncates to integer)', () => {
+ // Note: parseInt('1.5') = 1, which is valid
+ expect(validate('1.5')).toBe(true);
+ expect(validate('5.9')).toBe(true);
+ });
+ });
+
+ describe('auto-detect fields', () => {
+ it('authors field supports auto-detect', () => {
+ expect(INDEX_FIELD_METADATA.authors.auto).toBe(true);
+ });
+
+ it('dependence field supports auto-detect', () => {
+ expect(INDEX_FIELD_METADATA.dependence.auto).toBe(true);
+ });
+
+ it('base_text_titles field supports auto-detect', () => {
+ expect(INDEX_FIELD_METADATA.base_text_titles.auto).toBe(true);
+ });
+
+ it('collective_title field supports auto-detect', () => {
+ expect(INDEX_FIELD_METADATA.collective_title.auto).toBe(true);
+ });
+ });
+});
+
+describe('VERSION_FIELD_METADATA', () => {
+ describe('structure validation', () => {
+ it('has all expected fields', () => {
+ const expectedFields = [
+ 'versionTitle', 'versionTitleInHebrew', 'versionSource',
+ 'license', 'status', 'priority',
+ 'digitizedBySefaria', 'isPrimary', 'isSource',
+ 'versionNotes', 'versionNotesInHebrew',
+ 'purchaseInformationURL', 'purchaseInformationImage', 'direction'
+ ];
+ expectedFields.forEach(field => {
+ expect(VERSION_FIELD_METADATA).toHaveProperty(field);
+ });
+ });
+
+ it('each field has required properties', () => {
+ Object.entries(VERSION_FIELD_METADATA).forEach(([fieldName, meta]) => {
+ expect(meta).toHaveProperty('label');
+ expect(meta).toHaveProperty('type');
+ expect(typeof meta.label).toBe('string');
+ expect(['text', 'textarea', 'select', 'number']).toContain(meta.type);
+ });
+ });
+
+ it('versionTitle is marked as required', () => {
+ expect(VERSION_FIELD_METADATA.versionTitle.required).toBe(true);
+ });
+
+ it('Hebrew fields have rtl direction', () => {
+ expect(VERSION_FIELD_METADATA.versionTitleInHebrew.dir).toBe('rtl');
+ expect(VERSION_FIELD_METADATA.versionNotesInHebrew.dir).toBe('rtl');
+ });
+ });
+
+ describe('license options', () => {
+ const licenseOptions = VERSION_FIELD_METADATA.license.options;
+
+ it('includes common Creative Commons licenses', () => {
+ const values = licenseOptions.map(o => o.value);
+ expect(values).toContain('CC-BY');
+ expect(values).toContain('CC-BY-SA');
+ expect(values).toContain('CC-BY-NC');
+ expect(values).toContain('CC0');
+ });
+
+ it('includes Public Domain option', () => {
+ const values = licenseOptions.map(o => o.value);
+ expect(values).toContain('Public Domain');
+ });
+ });
+
+ describe('status options', () => {
+ const statusOptions = VERSION_FIELD_METADATA.status.options;
+
+ it('has empty value for editable', () => {
+ expect(statusOptions.find(o => o.value === '')).toBeDefined();
+ });
+
+ it('has locked value for staff-only', () => {
+ expect(statusOptions.find(o => o.value === 'locked')).toBeDefined();
+ });
+ });
+
+ describe('boolean-like select fields', () => {
+ const booleanFields = ['digitizedBySefaria', 'isPrimary', 'isSource'];
+
+ booleanFields.forEach(fieldName => {
+ it(`${fieldName} has true/false string options`, () => {
+ const options = VERSION_FIELD_METADATA[fieldName].options;
+ const values = options.map(o => o.value);
+ expect(values).toContain('true');
+ expect(values).toContain('false');
+ });
+ });
+ });
+});
+
+describe('BASE_TEXT_MAPPING_OPTIONS', () => {
+ it('is an array with expected options', () => {
+ expect(Array.isArray(BASE_TEXT_MAPPING_OPTIONS)).toBe(true);
+ expect(BASE_TEXT_MAPPING_OPTIONS.length).toBe(4);
+ });
+
+ it('each option has value and label', () => {
+ BASE_TEXT_MAPPING_OPTIONS.forEach(option => {
+ expect(option).toHaveProperty('value');
+ expect(option).toHaveProperty('label');
+ });
+ });
+
+ it('includes many_to_one_default_only for Mishnah/Tanakh', () => {
+ const option = BASE_TEXT_MAPPING_OPTIONS.find(o => o.value === 'many_to_one_default_only');
+ expect(option).toBeDefined();
+ expect(option.label).toContain('Mishnah');
+ });
+});
diff --git a/static/js/modtools/tests/stripHtmlTags.test.js b/static/js/modtools/tests/stripHtmlTags.test.js
new file mode 100644
index 0000000000..b8226b8c06
--- /dev/null
+++ b/static/js/modtools/tests/stripHtmlTags.test.js
@@ -0,0 +1,108 @@
+/**
+ * Tests for stripHtmlTags utility function
+ *
+ * This function is security-critical for XSS prevention.
+ * It safely extracts text content from HTML strings.
+ */
+import { stripHtmlTags } from '../components/shared';
+
+describe('stripHtmlTags', () => {
+ describe('basic HTML removal', () => {
+ it('removes simple HTML tags', () => {
+ expect(stripHtmlTags('Hello
')).toBe('Hello');
+ });
+
+ it('removes nested HTML tags', () => {
+ expect(stripHtmlTags('')).toBe('Nested');
+ });
+
+ it('removes self-closing tags', () => {
+ expect(stripHtmlTags('Hello World')).toBe('HelloWorld');
+ });
+
+ it('removes tags with attributes', () => {
+ expect(stripHtmlTags('Link ')).toBe('Link');
+ });
+
+ it('removes script tags', () => {
+ expect(stripHtmlTags('Safe')).toBe('alert("xss")Safe');
+ });
+
+ it('removes style tags', () => {
+ expect(stripHtmlTags('Text')).toBe('.red{color:red}Text');
+ });
+ });
+
+ describe('HTML entity decoding', () => {
+ it('decodes to space', () => {
+ expect(stripHtmlTags('Hello World')).toBe('Hello World');
+ });
+
+ it('decodes & to ampersand', () => {
+ expect(stripHtmlTags('Tom & Jerry')).toBe('Tom & Jerry');
+ });
+
+ it('decodes < to less-than', () => {
+ expect(stripHtmlTags('5 < 10')).toBe('5 < 10');
+ });
+
+ it('decodes > to greater-than', () => {
+ expect(stripHtmlTags('10 > 5')).toBe('10 > 5');
+ });
+
+ it('decodes " to quote', () => {
+ expect(stripHtmlTags('He said "hello"')).toBe('He said "hello"');
+ });
+
+ it('decodes multiple entities in one string', () => {
+ expect(stripHtmlTags('<div>& "test"')).toBe('& "test"');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns empty string for null', () => {
+ expect(stripHtmlTags(null)).toBe('');
+ });
+
+ it('returns empty string for undefined', () => {
+ expect(stripHtmlTags(undefined)).toBe('');
+ });
+
+ it('returns empty string for empty string', () => {
+ expect(stripHtmlTags('')).toBe('');
+ });
+
+ it('trims whitespace', () => {
+ expect(stripHtmlTags(' hello ')).toBe('hello');
+ });
+
+ it('handles plain text without HTML', () => {
+ expect(stripHtmlTags('Just plain text')).toBe('Just plain text');
+ });
+
+ it('handles malformed HTML', () => {
+ expect(stripHtmlTags('
Unclosed paragraph')).toBe('Unclosed paragraph');
+ });
+
+ it('handles empty tags', () => {
+ expect(stripHtmlTags('
')).toBe('');
+ });
+ });
+
+ describe('real-world error messages', () => {
+ it('strips HTML from typical error response', () => {
+ const errorHtml = '
Error: Invalid reference
';
+ expect(stripHtmlTags(errorHtml)).toBe('Error: Invalid reference');
+ });
+
+ it('handles multi-line HTML content', () => {
+ const multiLine = `
`;
+ const result = stripHtmlTags(multiLine);
+ expect(result).toContain('Error 1');
+ expect(result).toContain('Error 2');
+ });
+ });
+});
From 9013e669b6e6ced508f6ec471b36f65541f55d40 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Thu, 8 Jan 2026 14:40:11 +0200
Subject: [PATCH 10/26] docs(modtools): Add comprehensive documentation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add MODTOOLS_GUIDE.md for quick reference and usage
- Add COMPONENT_LOGIC.md for detailed implementation flows
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
docs/modtools/COMPONENT_LOGIC.md | 596 +++++++++++++++++++++++++++++++
docs/modtools/MODTOOLS_GUIDE.md | 332 +++++++++++++++++
2 files changed, 928 insertions(+)
create mode 100644 docs/modtools/COMPONENT_LOGIC.md
create mode 100644 docs/modtools/MODTOOLS_GUIDE.md
diff --git a/docs/modtools/COMPONENT_LOGIC.md b/docs/modtools/COMPONENT_LOGIC.md
new file mode 100644
index 0000000000..f24859e652
--- /dev/null
+++ b/docs/modtools/COMPONENT_LOGIC.md
@@ -0,0 +1,596 @@
+# ModTools Component Logic Documentation
+
+This document provides detailed logic flows, decision trees, and implementation rationale for each ModTools component.
+
+**Related**: [MODTOOLS_GUIDE.md](./MODTOOLS_GUIDE.md) - Overview, APIs, common tasks
+
+---
+
+## Table of Contents
+
+1. [Shared Components](#shared-components)
+2. [BulkVersionEditor](#bulkversioneditor)
+3. [BulkIndexEditor](#bulkindexeditor)
+4. [AutoLinkCommentaryTool](#autolinkcommentarytool)
+5. [NodeTitleEditor](#nodetitleeditor)
+6. [State Management Patterns](#state-management-patterns)
+7. [Error Handling Patterns](#error-handling-patterns)
+
+---
+
+## Shared Components
+
+### ModToolsSection
+
+**Purpose**: Unified wrapper providing consistent section styling, collapsible behavior, and integrated help.
+
+**State Machine**:
+```
+[COLLAPSED] <--toggle--> [EXPANDED]
+ | |
+ |--- ESC key ignored |--- ESC key ignored
+ |--- Click header: expand |--- Click header: collapse
+ |--- Enter/Space: expand |--- Enter/Space: collapse
+```
+
+**Key Logic**:
+```javascript
+// Default state: collapsed for cleaner initial page
+const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed = true);
+
+// Toggle on header click or keyboard
+const toggleCollapse = () => setIsCollapsed(prev => !prev);
+
+// Help button click must NOT toggle collapse
+const handleHelpClick = (e) => e.stopPropagation();
+```
+
+**Layout Structure**:
+```
+┌─────────────────────────────────────────────────────────┐
+│ [▼/▶] Title ................................ [?] │ <- sectionHeader
+├─────────────────────────────────────────────────────────┤
+│ │
+│ Section Content (animated hide/show) │ <- sectionContent
+│ │
+└─────────────────────────────────────────────────────────┘
+```
+
+**Animation**: CSS-based using `max-height` and `opacity` transitions. The collapsed state sets `max-height: 0` and `opacity: 0` with `pointer-events: none`.
+
+---
+
+### IndexSelector
+
+**Purpose**: Card-based grid for selecting indices with search filtering and bulk selection.
+
+**State**:
+```javascript
+const [searchFilter, setSearchFilter] = useState('');
+
+// Computed: filter indices by search term (title OR category match)
+const filteredIndices = useMemo(() => {
+ if (!searchFilter.trim()) return indices;
+ return indices.filter(idx =>
+ titleMatches(idx, search) || categoryMatches(idx, search)
+ );
+}, [indices, searchFilter, indexMetadata]);
+```
+
+**Selection Logic**:
+```
+User Action | Result
+---------------------|------------------------------------------
+Click card | Toggle single item
+Check card checkbox | Toggle single item (same as click)
+Select All checkbox | Add ALL FILTERED items to selection
+Uncheck Select All | Remove ALL FILTERED items from selection
+Clear search | Reset filter, selection unchanged
+```
+
+**Important**: Select All operates on FILTERED items only, not all items. This allows users to filter and bulk-select subsets.
+
+**Card Display**:
+```
+┌──────────────────────────┐
+│ [✓] Genesis │
+│ Tanakh • Torah │ <- category from indexMetadata
+└──────────────────────────┘
+```
+
+---
+
+### HelpButton
+
+**Purpose**: Modal dialog with detailed tool documentation.
+
+**State Machine**:
+```
+[CLOSED] <--click button--> [OPEN]
+ ^ |
+ |--- ESC key: close |--- Click overlay: close
+ |--- Click "Got it": close |
+```
+
+**Key Implementation Details**:
+
+1. **ESC Key Handling**: Uses capture phase (`addEventListener(..., true)`) to intercept ESC before other handlers. This prevents ESC from also closing the section or navigating away.
+
+2. **Body Scroll Lock**: When modal is open, sets `document.body.style.overflow = 'hidden'` to prevent background scrolling.
+
+3. **Focus Trap**: Modal content uses `onClick={e => e.stopPropagation()}` to prevent clicks inside from closing the modal.
+
+---
+
+### StatusMessage
+
+**Purpose**: Auto-formatted status display based on emoji prefix.
+
+**Detection Logic**:
+```javascript
+// Prefix detection for message type
+if (message.startsWith('✅')) return 'success';
+if (message.startsWith('❌')) return 'error';
+if (message.startsWith('⚠️')) return 'warning';
+return 'info';
+```
+
+This pattern allows components to simply set a message string like `"✅ Updated 5 versions"` and the UI automatically applies appropriate styling.
+
+---
+
+## BulkVersionEditor
+
+**Purpose**: Bulk edit Version metadata across multiple indices sharing a version title.
+
+### Workflow State Machine
+
+```
+[INITIAL]
+ |
+ v
+[ENTER_VERSION_TITLE] --> (empty) --> Error: "Please enter a version title"
+ |
+ v
+[LOADING] --> (API call to /api/version-indices)
+ |
+ ├── (success, results > 0) --> [RESULTS_LOADED]
+ | |
+ | v
+ | [INDICES_SELECTED]
+ | |
+ | ├── [EDITING_FIELDS]
+ | | |
+ | | v
+ | | [SAVING] --> (API call)
+ | | |
+ | | ├── (success) --> Clear fields, show success
+ | | ├── (partial) --> Show warning with failures
+ | | └── (error) --> Show error message
+ | |
+ | └── [MARK_FOR_DELETION]
+ | |
+ | v
+ | [CONFIRM_DIALOG]
+ | |
+ | ├── (confirm) --> Add deletion note
+ | └── (cancel) --> Return to editing
+ |
+ ├── (success, results = 0) --> [NO_RESULTS]
+ |
+ └── (error) --> [ERROR_STATE]
+```
+
+### Key Decision Points
+
+#### 1. URL Validation
+```javascript
+const URL_FIELDS = ['versionSource', 'purchaseInformationURL', 'purchaseInformationImage'];
+
+const isValidUrl = (string) => {
+ if (!string) return true; // Empty is valid (field is optional)
+ try {
+ new URL(string);
+ return true;
+ } catch (_) {
+ return false;
+ }
+};
+```
+
+#### 2. Boolean Field Conversion
+The API expects actual booleans, but HTML selects return strings:
+```javascript
+['digitizedBySefaria', 'isPrimary', 'isSource'].forEach(field => {
+ if (processedUpdates[field] === 'true') processedUpdates[field] = true;
+ else if (processedUpdates[field] === 'false') processedUpdates[field] = false;
+});
+```
+
+#### 3. Field Grouping
+Fields are organized into logical groups for UX:
+```javascript
+const FIELD_GROUPS = [
+ { id: 'identification', header: 'Version Identification',
+ fields: ['versionTitle', 'versionTitleInHebrew'] },
+ { id: 'source', header: 'Source & License',
+ fields: ['versionSource', 'license', 'purchaseInformationURL', 'purchaseInformationImage'] },
+ { id: 'metadata', header: 'Metadata',
+ fields: ['status', 'priority', 'digitizedBySefaria', 'isPrimary', 'isSource', 'direction'] },
+ { id: 'notes', header: 'Notes',
+ fields: ['versionNotes', 'versionNotesInHebrew'] }
+];
+```
+
+#### 4. Soft Delete Implementation
+Versions are NOT deleted immediately. Instead, a note is added:
+```javascript
+const deletionNote = `[MARKED FOR DELETION - ${new Date().toISOString().split('T')[0]}]`;
+// This note is searchable in MongoDB for cleanup scripts
+```
+
+### API Interaction
+
+**Load Indices**:
+```
+GET /api/version-indices?versionTitle=X&language=Y
+Response: { indices: [...], metadata: { indexTitle: { categories: [...] } } }
+```
+
+**Save Changes**:
+```
+POST /api/version-bulk-edit
+Body: { versionTitle, language, indices: [...], updates: {...} }
+Response: { status: "ok"|"partial"|"error", count, total, successes, failures }
+```
+
+---
+
+## BulkIndexEditor
+
+**Purpose**: Bulk edit Index (text catalog) metadata with auto-detection for commentaries.
+
+### Key Differences from BulkVersionEditor
+
+1. **Updates Index records** (text metadata) not Version records (translations)
+2. **Auto-detection** for commentary fields using "X on Y" title pattern
+3. **Author validation** against AuthorTopic database
+4. **Term creation** for collective titles
+5. **Sequential API calls** (one per index) instead of single bulk call
+
+### Commentary Auto-Detection Logic
+
+```javascript
+const detectCommentaryPattern = (title) => {
+ const match = title.match(/^(.+?)\s+on\s+(.+)$/);
+ if (match) {
+ return {
+ commentaryName: match[1].trim(), // e.g., "Rashi"
+ baseText: match[2].trim() // e.g., "Genesis"
+ };
+ }
+ return null;
+};
+```
+
+**Usage in auto-detection**:
+```javascript
+// If user enters 'auto' for a field:
+if (indexSpecificUpdates.dependence === 'auto') {
+ indexSpecificUpdates.dependence = pattern ? 'Commentary' : undefined;
+}
+
+if (indexSpecificUpdates.base_text_titles === 'auto') {
+ indexSpecificUpdates.base_text_titles = pattern ? [pattern.baseText] : undefined;
+}
+
+if (indexSpecificUpdates.collective_title === 'auto') {
+ indexSpecificUpdates.collective_title = pattern ? pattern.commentaryName : undefined;
+}
+```
+
+### Term Creation Flow
+
+Terms are required for collective titles to display properly:
+
+```javascript
+const createTermIfNeeded = async (enTitle, heTitle) => {
+ // 1. Check if term exists
+ try {
+ await $.get(`/api/terms/${encodeURIComponent(enTitle)}`);
+ return true; // Already exists
+ } catch (e) {
+ if (e.status === 404) {
+ // 2. Create new term
+ await $.post(`/api/terms/${encodeURIComponent(enTitle)}`, {
+ json: JSON.stringify({
+ name: enTitle,
+ titles: [
+ { lang: "en", text: enTitle, primary: true },
+ { lang: "he", text: heTitle, primary: true }
+ ]
+ })
+ });
+ }
+ }
+};
+```
+
+### TOC Zoom Handling
+
+TOC zoom is applied to schema nodes, not the index directly:
+
+```javascript
+if ('toc_zoom' in indexSpecificUpdates) {
+ const tocZoomValue = indexSpecificUpdates.toc_zoom;
+ delete indexSpecificUpdates.toc_zoom; // Remove from direct updates
+
+ // Apply to all JaggedArrayNode nodes in schema
+ if (existingIndexData.schema?.nodes) {
+ existingIndexData.schema.nodes.forEach(node => {
+ if (node.nodeType === "JaggedArrayNode") {
+ node.toc_zoom = tocZoomValue;
+ }
+ });
+ } else if (existingIndexData.schema) {
+ existingIndexData.schema.toc_zoom = tocZoomValue;
+ }
+}
+```
+
+---
+
+## AutoLinkCommentaryTool
+
+**Purpose**: Create automatic links between commentaries and their base texts.
+
+### Workflow
+
+```
+[SEARCH] --> Filter for " on " pattern --> [COMMENTARIES_FOUND]
+ |
+ v
+ [SELECT_COMMENTARIES]
+ |
+ v
+ [CHOOSE_MAPPING]
+ |
+ v
+ [CREATE_LINKS]
+ |
+ For each commentary:
+ 1. Fetch index data
+ 2. Extract base text from title
+ 3. Patch index with commentary metadata
+ 4. Clear caches
+ 5. Rebuild auto-links
+```
+
+### Mapping Algorithm Selection
+
+| Algorithm | Use Case | Structure |
+|-----------|----------|-----------|
+| `many_to_one_default_only` | Most commentaries | Rashi 1:1:1, 1:1:2, 1:1:3 → Genesis 1:1 |
+| `many_to_one` | With alt structures | Same + alternate verse numberings |
+| `one_to_one_default_only` | Translations | Chapter 1:1 = Chapter 1:1 |
+| `one_to_one` | Translations + alts | Same + alternate structures |
+
+### Idempotency
+
+The tool is idempotent - running it multiple times is safe:
+```javascript
+if (!raw.base_text_titles || !raw.base_text_mapping) {
+ // Only patch if fields are missing
+ const patched = { ...raw, dependence: "Commentary", ... };
+ await $.post(url, { json: JSON.stringify(patched) });
+}
+// Always rebuild links (safe to re-run)
+await $.get(`/admin/rebuild/auto-links/${title}`);
+```
+
+---
+
+## NodeTitleEditor
+
+**Purpose**: Edit titles of schema nodes within an Index.
+
+### Node Extraction Logic
+
+Recursively traverses the schema to build a flat list of editable nodes:
+
+```javascript
+const extractNodes = (schema, path = []) => {
+ let nodes = [];
+
+ if (schema.nodes) {
+ schema.nodes.forEach((node, index) => {
+ const nodePath = [...path, index];
+ nodes.push({
+ path: nodePath,
+ pathStr: nodePath.join('.'), // "0.1.2" for nodes[0].nodes[1].nodes[2]
+ node: node,
+ sharedTitle: node.sharedTitle,
+ title: node.title,
+ heTitle: node.heTitle
+ });
+
+ // Recurse into children
+ if (node.nodes) {
+ nodes = nodes.concat(extractNodes(node, nodePath));
+ }
+ });
+ }
+
+ return nodes;
+};
+```
+
+### Validation Rules
+
+```javascript
+// English titles: ASCII only, no special characters
+const isValidEnglishTitle = (title) => {
+ const isAscii = title.match(/^[\x00-\x7F]*$/);
+ const noForbidden = !title.match(/[:.\\/-]/);
+ return isAscii && noForbidden;
+};
+
+// Hebrew titles: No restrictions (any Unicode allowed)
+```
+
+### Shared Title Handling
+
+Some nodes use "shared titles" (Terms) that can be reused across texts:
+
+```javascript
+// When removing shared title:
+if (edits.removeSharedTitle) {
+ delete targetNode.sharedTitle;
+ // Node will now use direct title/heTitle fields
+}
+```
+
+### Dependency Warning
+
+Before editing, the tool checks what depends on this index:
+```javascript
+const checkDependencies = async (title) => {
+ const response = await $.get(`/api/check-index-dependencies/${title}`);
+ // Response includes: dependent_count, dependent_indices, version_count, link_count
+};
+```
+
+---
+
+## State Management Patterns
+
+### Common State Categories
+
+All tools follow this pattern:
+
+```javascript
+// 1. Search/Filter State
+const [vtitle, setVtitle] = useState("");
+const [lang, setLang] = useState("");
+const [searched, setSearched] = useState(false);
+
+// 2. Results State
+const [indices, setIndices] = useState([]);
+const [indexMetadata, setIndexMetadata] = useState({});
+const [pick, setPick] = useState(new Set()); // Selected items
+
+// 3. Edit State
+const [updates, setUpdates] = useState({});
+const [validationErrors, setValidationErrors] = useState({});
+
+// 4. UI State
+const [msg, setMsg] = useState("");
+const [loading, setLoading] = useState(false);
+const [saving, setSaving] = useState(false);
+```
+
+### Selection Pattern (Set-based)
+
+```javascript
+// Using Set for O(1) add/remove/check
+const [pick, setPick] = useState(new Set());
+
+// Toggle single item
+const toggleOne = (index, checked) => {
+ const newSet = new Set(pick);
+ if (checked) newSet.add(index);
+ else newSet.delete(index);
+ setPick(newSet);
+};
+
+// Bulk operations
+const selectAll = () => setPick(new Set([...pick, ...filteredIndices]));
+const deselectAll = () => {
+ const newSet = new Set(pick);
+ filteredIndices.forEach(idx => newSet.delete(idx));
+ setPick(newSet);
+};
+```
+
+---
+
+## Error Handling Patterns
+
+### API Error Extraction
+
+```javascript
+const extractErrorMessage = (xhr) => {
+ return xhr.responseJSON?.error ||
+ xhr.responseText ||
+ xhr.statusText ||
+ "Unknown error";
+};
+```
+
+### Partial Success Handling
+
+```javascript
+// API returns detailed status
+if (d.status === "ok") {
+ setMsg(`✅ Successfully updated ${d.count} versions`);
+} else if (d.status === "partial") {
+ const failures = d.failures.map(f => `${f.index}: ${f.error}`).join("; ");
+ setMsg(`⚠️ Updated ${d.count}/${d.total}. Failures: ${failures}`);
+} else {
+ setMsg(`❌ All updates failed`);
+}
+```
+
+### Validation Before Save
+
+```javascript
+const save = () => {
+ // 1. Check selections exist
+ if (!pick.size) {
+ setMsg("❌ No indices selected");
+ return;
+ }
+
+ // 2. Check changes exist
+ if (!Object.keys(updates).length) {
+ setMsg("❌ No fields to update");
+ return;
+ }
+
+ // 3. Check validation errors
+ if (Object.keys(validationErrors).length > 0) {
+ setMsg("❌ Please fix validation errors before saving");
+ return;
+ }
+
+ // Proceed with save...
+};
+```
+
+---
+
+## Appendix: File Dependencies
+
+```
+BulkVersionEditor.jsx
+ ├── imports: VERSION_FIELD_METADATA (fieldMetadata.js)
+ ├── imports: ModToolsSection, IndexSelector, StatusMessage (shared/)
+ └── API: /api/version-indices, /api/version-bulk-edit
+
+BulkIndexEditor.jsx
+ ├── imports: INDEX_FIELD_METADATA (fieldMetadata.js)
+ ├── imports: Sefaria (for getIndexDetails)
+ ├── imports: ModToolsSection, IndexSelector, StatusMessage (shared/)
+ └── API: /api/version-indices, /api/v2/raw/index, /admin/reset, /api/terms
+
+AutoLinkCommentaryTool.jsx
+ ├── imports: BASE_TEXT_MAPPING_OPTIONS (fieldMetadata.js)
+ ├── imports: Sefaria (for getIndexDetails)
+ ├── imports: ModToolsSection, IndexSelector, StatusMessage (shared/)
+ └── API: /api/version-indices, /api/v2/raw/index, /admin/reset, /admin/rebuild/auto-links
+
+NodeTitleEditor.jsx
+ ├── imports: Sefaria (for getIndexDetails)
+ ├── imports: ModToolsSection, StatusMessage (shared/)
+ └── API: /api/v2/raw/index, /admin/reset, /api/check-index-dependencies
+```
diff --git a/docs/modtools/MODTOOLS_GUIDE.md b/docs/modtools/MODTOOLS_GUIDE.md
new file mode 100644
index 0000000000..4f1af0ad11
--- /dev/null
+++ b/docs/modtools/MODTOOLS_GUIDE.md
@@ -0,0 +1,332 @@
+# ModTools Guide
+
+This document provides comprehensive documentation for the ModeratorToolsPanel (`/modtools`), an internal admin interface for Sefaria staff to perform bulk operations on texts.
+
+**Related**: [COMPONENT_LOGIC.md](./COMPONENT_LOGIC.md) - Detailed logic flows for each component
+
+---
+
+## Quick Start
+
+### What is ModTools?
+
+ModTools is an internal admin interface at `/modtools` for Sefaria staff. Access is restricted to authenticated moderators (`Sefaria.is_moderator`).
+
+**URL**: `/modtools`
+**Frontend Entry**: `static/js/ModeratorToolsPanel.jsx`
+**Backend Views**: `sefaria/views.py`
+
+### Key Files
+
+| File | Purpose | When to Modify |
+|------|---------|----------------|
+| `static/js/ModeratorToolsPanel.jsx` | Main panel, imports tools | Adding new tools |
+| `static/js/modtools/components/*.jsx` | Individual tools | Editing tool behavior |
+| `static/js/modtools/components/shared/*.jsx` | Shared components | Editing shared UI |
+| `static/js/modtools/constants/fieldMetadata.js` | Field definitions | Adding/editing fields |
+| `static/css/modtools.css` | All styles | UI changes |
+| `sefaria/views.py` | Backend APIs | API changes |
+
+---
+
+## File Structure
+
+```
+static/js/
+├── ModeratorToolsPanel.jsx # Main container, legacy tools
+│
+└── modtools/
+ ├── index.js # Module exports
+ ├── constants/
+ │ └── fieldMetadata.js # VERSION_FIELD_METADATA, INDEX_FIELD_METADATA
+ └── components/
+ ├── BulkVersionEditor.jsx # Version metadata bulk editor
+ ├── BulkIndexEditor.jsx # Index metadata (disabled)
+ ├── AutoLinkCommentaryTool.jsx # Commentary linker (disabled)
+ ├── NodeTitleEditor.jsx # Node title editor (disabled)
+ └── shared/
+ ├── index.js
+ ├── ModToolsSection.jsx # Collapsible section wrapper
+ ├── HelpButton.jsx # Help modal button
+ ├── IndexSelector.jsx # Selection grid
+ └── StatusMessage.jsx # Message display
+
+static/css/
+└── modtools.css # All modtools styles
+
+sefaria/
+├── views.py # Backend API handlers
+├── urls.py # Route definitions
+└── model/text.py # Version and Index models
+```
+
+---
+
+## Current Components
+
+### Active Tools
+
+**1. Bulk Download Text**
+Downloads versions matching title/version patterns.
+- Endpoint: `GET /download/bulk/versions/`
+
+**2. Bulk Upload CSV**
+Uploads text content from CSV files.
+- Endpoint: `POST /api/text-upload`
+
+**3. Workflowy Outline Upload**
+Imports text structure from OPML files.
+- Endpoint: `POST /modtools/upload_text`
+
+**4. Upload Links (from CSV)**
+Creates link records from CSV.
+- Endpoint: `POST /modtools/links`
+
+**5. Download Links**
+Exports links between refs to CSV.
+- Endpoint: `GET /modtools/links/{ref1}/{ref2}`
+
+**6. Remove Links (from CSV)**
+Deletes links from CSV.
+- Endpoint: `POST /modtools/links` with `action: "DELETE"`
+
+**7. Bulk Edit Version Metadata**
+Edit metadata across multiple Version records.
+- Endpoints: `GET /api/version-indices`, `POST /api/version-bulk-edit`
+
+### Disabled Tools (Backend APIs Remain Functional)
+
+- **BulkIndexEditor**: Bulk edit index metadata
+- **AutoLinkCommentaryTool**: Auto-link commentaries
+- **NodeTitleEditor**: Edit schema node titles
+
+---
+
+## API Endpoints
+
+### ModTools-Specific APIs
+
+| Endpoint | Method | Purpose | Auth |
+|----------|--------|---------|------|
+| `/api/version-indices` | GET | Find indices with matching version | None |
+| `/api/version-bulk-edit` | POST | Bulk update version metadata | Staff |
+| `/api/check-index-dependencies/{title}` | GET | Check index dependencies | Staff |
+
+### Version Bulk Edit
+
+**Request**:
+```json
+{
+ "versionTitle": "Kehati",
+ "language": "he",
+ "indices": ["Mishnah Berakhot", "Mishnah Peah"],
+ "updates": { "license": "CC-BY", "priority": 1.5 }
+}
+```
+
+**Response**:
+```json
+{
+ "status": "ok" | "partial" | "error",
+ "successes": ["Mishnah Berakhot", ...],
+ "failures": [{"index": "Mishnah Peah", "error": "..."}]
+}
+```
+
+**Field Clearing**: Send `null` for a field to remove it entirely from MongoDB.
+
+---
+
+## Data Models
+
+### Version Model (`sefaria/model/text.py`)
+
+**Collection**: `texts`
+
+**Editable Fields** (via bulk edit):
+| Field | Type | Description |
+|-------|------|-------------|
+| `status` | string | "locked" = non-staff can't edit |
+| `priority` | float | Display ordering |
+| `license` | string | e.g., "CC-BY", "Public Domain" |
+| `versionNotes` | string | English notes |
+| `versionNotesInHebrew` | string | Hebrew notes |
+| `versionTitleInHebrew` | string | Hebrew version title |
+| `digitizedBySefaria` | boolean | Sefaria digitized flag |
+| `isPrimary` | boolean | Primary version flag |
+| `isSource` | boolean | Source (not translation) flag |
+| `purchaseInformationURL` | string | Purchase link |
+| `purchaseInformationImage` | string | Purchase image |
+| `direction` | string | "rtl" or "ltr" |
+
+### Index Model (`sefaria/model/text.py`)
+
+**Collection**: `index`
+
+**Key Fields for Commentaries**:
+| Field | Type | Description |
+|-------|------|-------------|
+| `dependence` | string | "Commentary" or "Targum" |
+| `base_text_titles` | list | What it comments on |
+| `base_text_mapping` | string | AutoLinker mapping type |
+| `collective_title` | string | Requires matching Term |
+| `authors` | list | Slugs from AuthorTopic |
+
+---
+
+## Shared Components
+
+### ModToolsSection
+
+Collapsible wrapper with help button.
+
+```jsx
+Help ...
>}
+>
+ {/* Tool content */}
+
+```
+
+**Props**: `title`, `titleHe`, `helpContent`, `helpTitle`, `defaultCollapsed`, `className`
+
+### IndexSelector
+
+Card-based grid for selecting indices.
+
+**Props**: `indices`, `selectedIndices`, `onSelectionChange`, `label`, `indexMetadata`, `maxHeight`
+
+**Key Behavior**:
+- Search filters by title AND category
+- Select All operates on FILTERED items only
+
+### StatusMessage
+
+Auto-styled status display. Accepts string or `{type, message}` object.
+
+**MESSAGE_TYPES**: `SUCCESS`, `ERROR`, `WARNING`, `INFO`
+
+### HelpButton
+
+Modal with documentation. ESC key closes, body scroll locked when open.
+
+---
+
+## Common Tasks
+
+### Adding a New Field to BulkVersionEditor
+
+1. **Add to fieldMetadata.js**:
+```javascript
+"newField": {
+ label: "New Field",
+ type: "text" | "textarea" | "select" | "number",
+ placeholder: "...",
+ help: "Description",
+ dir: "rtl" // if Hebrew
+}
+```
+
+2. **Add to FIELD_GROUPS** (BulkVersionEditor.jsx):
+```javascript
+const FIELD_GROUPS = [
+ { id: 'metadata', fields: [..., 'newField'] }
+];
+```
+
+### Adding a New Tool
+
+1. Create component: `static/js/modtools/components/NewTool.jsx`
+2. Export from: `static/js/modtools/index.js`
+3. Import in: `ModeratorToolsPanel.jsx`
+4. Add CSS if needed: `modtools.css`
+
+**Template**:
+```jsx
+import React, { useState } from 'react';
+import ModToolsSection from './shared/ModToolsSection';
+import StatusMessage from './shared/StatusMessage';
+
+const NewTool = () => {
+ const [msg, setMsg] = useState("");
+ return (
+
+ {/* Tool content */}
+
+
+ );
+};
+export default NewTool;
+```
+
+---
+
+## Patterns & Conventions
+
+### State Pattern
+
+```javascript
+// Search state
+const [vtitle, setVtitle] = useState("");
+const [searched, setSearched] = useState(false);
+
+// Results state
+const [indices, setIndices] = useState([]);
+const [pick, setPick] = useState(new Set());
+
+// Edit state
+const [updates, setUpdates] = useState({});
+const [validationErrors, setValidationErrors] = useState({});
+
+// UI state
+const [msg, setMsg] = useState("");
+const [loading, setLoading] = useState(false);
+```
+
+### Selection Pattern
+
+```javascript
+const [pick, setPick] = useState(new Set());
+
+const toggleOne = (idx, checked) => {
+ const newSet = new Set(pick);
+ if (checked) newSet.add(idx);
+ else newSet.delete(idx);
+ setPick(newSet);
+};
+```
+
+### Boolean Field Handling
+
+HTML `` returns strings. Convert before API call:
+```javascript
+if (value === 'true') value = true;
+if (value === 'false') value = false;
+```
+
+---
+
+## Troubleshooting
+
+| Issue | Cause | Fix |
+|-------|-------|-----|
+| "Version not found" | Case-sensitive matching | Use exact capitalization |
+| "Author not found" | Author must exist in AuthorTopic | Check `/api/name/{name}` |
+| Timeout on large operations | 50+ versions may timeout | Process in smaller batches |
+| Changes not appearing | Cache not cleared | Call `/admin/reset/{title}` |
+
+---
+
+## CSS Classes
+
+Key classes in `modtools.css`:
+
+| Class | Purpose |
+|-------|---------|
+| `.modToolsSection` | Collapsible section wrapper |
+| `.modtoolsButton` | Action buttons (`.secondary`, `.danger`, `.small`) |
+| `.indexCard` | Selection card (`.selected` = blue border) |
+| `.message` | Status messages (`.success`, `.error`, `.warning`, `.info`) |
+| `.fieldGroupGrid` | 2-column field layout |
From dc06b5403443b1208d19cf1c2f3123743e4817ce Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Thu, 8 Jan 2026 14:40:20 +0200
Subject: [PATCH 11/26] chore(modtools): Add disabled tool components for
future use
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Note: These components are disabled and do not need review.
Open tickets exist to reintroduce them:
- BulkIndexEditor: Bulk edit index metadata
- AutoLinkCommentaryTool: Auto-link commentaries to base texts
- NodeTitleEditor: Edit node titles within Index schemas
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
sefaria/export.py | 2 +-
sefaria/views.py | 12 +-
.../components/AutoLinkCommentaryTool.jsx | 409 +++++++++++
.../modtools/components/BulkIndexEditor.jsx | 671 ++++++++++++++++++
.../modtools/components/NodeTitleEditor.jsx | 501 +++++++++++++
static/js/modtools/components/shared/index.js | 21 +-
static/js/modtools/index.js | 51 +-
7 files changed, 1644 insertions(+), 23 deletions(-)
create mode 100644 static/js/modtools/components/AutoLinkCommentaryTool.jsx
create mode 100644 static/js/modtools/components/BulkIndexEditor.jsx
create mode 100644 static/js/modtools/components/NodeTitleEditor.jsx
diff --git a/sefaria/export.py b/sefaria/export.py
index 247b6d55a1..aec83c90e9 100644
--- a/sefaria/export.py
+++ b/sefaria/export.py
@@ -765,4 +765,4 @@ def _import_versions_from_csv(rows, columns, user_id):
"versionNotes": notes,
}).save()
- modify_bulk_text(user_id, v, text_map, type=action)
\ No newline at end of file
+ modify_bulk_text(user_id, v, text_map, type=action)
diff --git a/sefaria/views.py b/sefaria/views.py
index f71e8e5c97..25718ecc81 100644
--- a/sefaria/views.py
+++ b/sefaria/views.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
import io
import os
import zipfile
@@ -1551,6 +1550,7 @@ def bulk_download_versions_api(request):
return response
+
def _get_text_version_file(format, title, lang, versionTitle):
from sefaria.export import text_is_copyright, make_json, make_text, prepare_merged_text_for_export, prepare_text_for_export, export_merged_csv, export_version_csv
@@ -1597,7 +1597,6 @@ def _get_text_version_file(format, title, lang, versionTitle):
return content
-
@staff_member_required
def text_upload_api(request):
if request.method != "POST":
@@ -1617,6 +1616,8 @@ def text_upload_api(request):
return jsonResponse({"status": "ok", "message": message})
+
+
@staff_member_required
def version_indices_api(request):
"""
@@ -1732,7 +1733,7 @@ def check_index_dependencies_api(request, title):
Used by NodeTitleEditor to warn about potential impacts of title changes.
NOTE: NodeTitleEditor is currently disabled in ModeratorToolsPanel.
- This endpoint is not in active use and should be reviewed when used but retained for future re-enablement.
+ This endpoint is not in active use and should be reviewd when used but retained for future re-enablement.
"""
if request.method != "GET":
return jsonResponse({"error": "GET required"})
@@ -1765,7 +1766,6 @@ def check_index_dependencies_api(request, title):
except Exception as e:
return jsonResponse({"error": str(e)})
-
@staff_member_required
def update_authors_from_sheet(request):
from sefaria.helper.descriptions import update_authors_data
@@ -1800,7 +1800,7 @@ def modtools_upload_workflowy(request):
# Handle checkbox parameters
c_index = request.POST.get("c_index", "").lower() == "true"
c_version = request.POST.get("c_version", "").lower() == "true"
-
+
delims = request.POST.get("delims") if request.POST.get("delims", "") != "" else None
term_scheme = request.POST.get("term_scheme") if request.POST.get("term_scheme", "") != "" else None
@@ -1879,4 +1879,4 @@ def compare(request, comp_ref=None, lang=None, v1=None, v2=None):
'lang': lang,
'refArray': ref_array,
})
- })
+ })
\ No newline at end of file
diff --git a/static/js/modtools/components/AutoLinkCommentaryTool.jsx b/static/js/modtools/components/AutoLinkCommentaryTool.jsx
new file mode 100644
index 0000000000..9d5c9973bc
--- /dev/null
+++ b/static/js/modtools/components/AutoLinkCommentaryTool.jsx
@@ -0,0 +1,409 @@
+/**
+ * AutoLinkCommentaryTool - Automatically create links between commentaries and base texts
+ *
+ * NOTE: This component is currently DISABLED in ModeratorToolsPanel.
+ * It is retained for future re-enablement but not rendered in the UI.
+ *
+ * This tool helps set up the commentary linking infrastructure for texts that follow
+ * the "X on Y" naming pattern (e.g., "Rashi on Genesis").
+ *
+ * Workflow:
+ * 1. User enters a versionTitle to find commentary indices
+ * 2. Tool filters for indices with " on " in the title
+ * 3. User selects which commentaries to process
+ * 4. Tool patches each Index with:
+ * - dependence: "Commentary"
+ * - base_text_titles: [guessed base text]
+ * - base_text_mapping: selected algorithm
+ * 5. Tool triggers /admin/rebuild/auto-links/ for each
+ *
+ * Backend APIs:
+ * - POST /api/v2/raw/index/{title}?update=1 - Update index record
+ * - GET /admin/reset/{title} - Clear caches
+ * - GET /admin/rebuild/auto-links/{title} - Rebuild commentary links
+ *
+ * Documentation:
+ * - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
+ * - See /docs/modtools/COMPONENT_LOGIC.md for detailed implementation logic
+ * - Mapping algorithms are defined in ../constants/fieldMetadata.js
+ */
+import React, { useState } from 'react';
+import Sefaria from '../../sefaria/sefaria';
+import { BASE_TEXT_MAPPING_OPTIONS } from '../constants/fieldMetadata';
+import ModToolsSection from './shared/ModToolsSection';
+import IndexSelector from './shared/IndexSelector';
+import StatusMessage from './shared/StatusMessage';
+
+/**
+ * Detailed help documentation for this tool
+ */
+const HELP_CONTENT = (
+ <>
+ What This Tool Does
+
+ This tool automatically creates links between commentaries and their base texts .
+ When a user views "Genesis 1:1", they see connections to "Rashi on Genesis 1:1:1" and other
+ commentaries. This tool generates those connections automatically.
+
+
+ The tool works by setting up commentary metadata on the Index record and then triggering
+ Sefaria's auto-linking system to generate the actual link records.
+
+
+ How It Works
+
+ Search: Enter a version title to find commentary texts (texts with "X on Y" pattern).
+ Select: Choose which commentaries to link.
+ Choose mapping: Select how commentary sections map to base text sections.
+ Create Links: The tool patches each Index with commentary metadata, then triggers link building.
+
+
+ What Gets Changed
+ For each selected commentary, the tool sets:
+
+ dependence: "Commentary" - Marks the text as a commentary
+ base_text_titles - The text(s) being commented on (extracted from title pattern)
+ base_text_mapping - The algorithm for mapping commentary refs to base refs
+
+ Then it calls /admin/rebuild/auto-links/ to generate the actual link records.
+
+ Mapping Algorithms
+
+
+ Algorithm Use Case Example
+
+
+
+ many_to_one_default_only
+ Most common. Multiple commentary segments per verse.
+ Rashi on Genesis 1:1:1, 1:1:2, 1:1:3 all link to Genesis 1:1
+
+
+ many_to_one
+ Like above but includes alt structures.
+ Same as above, but also links to alt verse numberings
+
+
+ one_to_one_default_only
+ One commentary segment per base segment.
+ Translation where Chapter 1:1 = Chapter 1:1
+
+
+ one_to_one
+ Like above but includes alt structures.
+ Same as above with alt structures
+
+
+
+
+
+ Which mapping should I use?
+ For most Tanakh commentaries (Rashi, Ibn Ezra, etc.) and Mishnah commentaries (Kehati, Bartenura),
+ use many_to_one_default_only. This is the default and works for commentaries where
+ each verse/mishnah has multiple comment segments.
+
+
+ What "X on Y" Pattern Means
+
+ The tool only works with texts that follow the "X on Y" naming pattern:
+
+
+ "Rashi on Genesis" - Commentary name is "Rashi", base text is "Genesis"
+ "Kehati on Mishnah Berakhot" - Commentary is "Kehati", base is "Mishnah Berakhot"
+ "Ibn Ezra on Psalms" - Commentary is "Ibn Ezra", base is "Psalms"
+
+
+ The tool extracts the base text name from after " on " in the title and uses that
+ to set base_text_titles.
+
+
+
+
Important Notes:
+
+ This tool is idempotent - running it multiple times is safe.
+ Only texts with " on " in their title are shown (non-commentaries are filtered out).
+ The base text (e.g., "Genesis") must already exist in Sefaria for links to work.
+ Link building may take a few seconds per text.
+ Links update automatically when text content changes.
+
+
+
+ Common Use Cases
+
+ Setting up links for a new commentary series just uploaded
+ Re-linking commentaries after the base text structure changed
+ Fixing commentaries that were uploaded without proper linking metadata
+
+
+ Troubleshooting
+
+ "Title pattern didn't reveal base text" - The index title doesn't match "X on Y" pattern. Rename the index first.
+ Links not appearing - Make sure the base text exists and the ref structure matches.
+ Wrong links - Try a different mapping algorithm. "many_to_one" vs "one_to_one" depends on commentary structure.
+
+ >
+);
+
+const AutoLinkCommentaryTool = () => {
+ // Search state
+ const [vtitle, setVtitle] = useState("");
+ const [lang, setLang] = useState("");
+ const [searched, setSearched] = useState(false);
+
+ // Results state
+ // indices: Array of {title: string, categories?: string[]} objects
+ const [indices, setIndices] = useState([]);
+ const [pick, setPick] = useState(new Set());
+
+ // Options state
+ const [mapping, setMapping] = useState("many_to_one_default_only");
+
+ // UI state
+ const [msg, setMsg] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [linking, setLinking] = useState(false);
+
+ /**
+ * Clear search and reset state
+ */
+ const clearSearch = () => {
+ setIndices([]);
+ setPick(new Set());
+ setMsg("");
+ setSearched(false);
+ };
+
+ /**
+ * Load indices that have " on " in their title (commentary pattern)
+ */
+ const load = async () => {
+ if (!vtitle.trim()) {
+ setMsg("Please enter a version title");
+ return;
+ }
+
+ setLoading(true);
+ setSearched(true);
+ setMsg("Loading indices...");
+
+ const urlParams = { versionTitle: vtitle };
+ if (lang) {
+ urlParams.language = lang;
+ }
+
+ try {
+ const data = await Sefaria.apiRequestWithBody('/api/version-indices', urlParams, null, 'GET');
+ const resultMetadata = data.metadata || {};
+ // Filter for commentary pattern and combine with metadata
+ const commentaryIndices = (data.indices || [])
+ .filter(title => title.includes(" on "))
+ .map(title => ({
+ title,
+ categories: resultMetadata[title]?.categories
+ }));
+ setIndices(commentaryIndices);
+ setPick(new Set(commentaryIndices.map(item => item.title))); // Set of title strings
+ if (commentaryIndices.length > 0) {
+ setMsg(`Found ${commentaryIndices.length} commentaries with version "${vtitle}"`);
+ } else {
+ setMsg("");
+ }
+ } catch (error) {
+ setMsg(`Error: ${error.message || "Failed to load indices"}`);
+ setIndices([]);
+ setPick(new Set());
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ /**
+ * Create links for selected commentaries
+ */
+ const createLinks = async () => {
+ if (!pick.size) return;
+
+ setLinking(true);
+ setMsg("Creating links...");
+
+ let successCount = 0;
+ const errors = [];
+
+ for (const indexTitle of pick) {
+ try {
+ // 1. Fetch current Index
+ const raw = await Sefaria.getIndexDetails(indexTitle);
+ if (!raw) throw new Error("Couldn't fetch index JSON");
+
+ // 2. Guess base work from "... on " pattern
+ const guess = (indexTitle.match(/ on (.+)$/) || [])[1];
+ if (!guess) throw new Error("Title pattern didn't reveal base text");
+
+ // 3. Add missing commentary metadata (idempotent)
+ if (!raw.base_text_titles || !raw.base_text_mapping) {
+ const patched = {
+ ...raw,
+ dependence: "Commentary",
+ base_text_titles: raw.base_text_titles || [guess],
+ base_text_mapping: raw.base_text_mapping || mapping
+ };
+ delete patched._id;
+
+ const urlParams = { update: '1' };
+ const indexPath = encodeURIComponent(indexTitle.replace(/ /g, "_"));
+ const payload = { json: JSON.stringify(patched) };
+
+ // Update index via raw API
+ await Sefaria.apiRequestWithBody(`/api/v2/raw/index/${indexPath}`, urlParams, payload);
+
+ // Clear caches (non-JSON endpoint)
+ const resetResponse = await fetch(`/admin/reset/${encodeURIComponent(indexTitle)}`, {
+ method: 'GET',
+ credentials: 'same-origin'
+ });
+ if (!resetResponse.ok) {
+ throw new Error("Failed to reset cache");
+ }
+ }
+
+ // 4. Rebuild links (non-JSON endpoint)
+ setMsg(`Building links for ${indexTitle}...`);
+ const rebuildPath = encodeURIComponent(indexTitle.replace(/ /g, "_"));
+ const rebuildResponse = await fetch(`/admin/rebuild/auto-links/${rebuildPath}`, {
+ method: 'GET',
+ credentials: 'same-origin'
+ });
+ if (!rebuildResponse.ok) {
+ throw new Error("Failed to rebuild links");
+ }
+
+ successCount++;
+ } catch (e) {
+ const m = e.message || "Unknown error";
+ errors.push(`${indexTitle}: ${m}`);
+ }
+ }
+
+ setMsg(
+ errors.length
+ ? `Finished. Linked ${successCount}/${pick.size}. Errors: ${errors.join("; ")}`
+ : `Links built for all ${successCount} indices`
+ );
+ setLinking(false);
+ };
+
+ return (
+
+ {/* Info box */}
+
+ How it works: This tool automatically creates links between commentaries
+ and their base texts. For example, "Rashi on Genesis 1:1:1" will be linked to "Genesis 1:1".
+ Links update dynamically when text changes.
+
+
+ {/* Search bar */}
+
+ setVtitle(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && load()}
+ />
+
+ {loading ? <> Searching...> : "Find Commentaries"}
+
+
+
+ {/* Language filter - inline */}
+
+ Filter by language:
+ setLang(e.target.value)}
+ >
+ All languages
+ Hebrew only
+ English only
+
+
+
+ {/* Clear button - centered */}
+ {searched && (
+
+
+ Clear Search
+
+
+ )}
+
+ {/* No results message */}
+ {searched && !loading && indices.length === 0 && (
+
+ No commentaries found with version "{vtitle}"
+ This tool only finds texts with " on " in their title (e.g., "Rashi on Genesis").
+ Verify the version title is correct and contains commentary texts.
+
+ )}
+
+ {/* Index selector */}
+ {indices.length > 0 && (
+ <>
+
+
+ {/* Mapping selector */}
+
+ base_text_mapping:
+ setMapping(e.target.value)}
+ >
+ {BASE_TEXT_MAPPING_OPTIONS.map(opt => (
+ {opt.label}
+ ))}
+
+
+
+ {/* Action button */}
+
+
+ {linking ? (
+ <> Creating Links...>
+ ) : (
+ `Create Links for ${pick.size} Commentaries`
+ )}
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default AutoLinkCommentaryTool;
diff --git a/static/js/modtools/components/BulkIndexEditor.jsx b/static/js/modtools/components/BulkIndexEditor.jsx
new file mode 100644
index 0000000000..14b445fbcc
--- /dev/null
+++ b/static/js/modtools/components/BulkIndexEditor.jsx
@@ -0,0 +1,671 @@
+/**
+ * BulkIndexEditor - Bulk edit Index metadata across multiple indices
+ *
+ * NOTE: This component is currently DISABLED in ModeratorToolsPanel.
+ * It is retained for future re-enablement but not rendered in the UI.
+ *
+ * Similar workflow to BulkVersionEditor, but operates on Index records
+ * (the text metadata) rather than Version records (text content/translations).
+ *
+ * Workflow:
+ * 1. User enters a versionTitle to find indices that have matching versions
+ * 2. User selects which indices to update
+ * 3. User fills in index metadata fields
+ * 4. On save, updates each Index via the raw API
+ *
+ * Special features:
+ * - Auto-detection for commentary texts ("X on Y" pattern)
+ * - Automatic term creation for collective titles
+ * - Author validation against AuthorTopic
+ * - TOC zoom level setting on schema nodes
+ *
+ * Backend API: POST /api/v2/raw/index/{title}?update=1
+ *
+ * Documentation:
+ * - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
+ * - See /docs/modtools/COMPONENT_LOGIC.md for detailed implementation logic
+ * - Index fields are defined in ../constants/fieldMetadata.js
+ */
+import { useState, useEffect } from 'react';
+import Sefaria from '../../sefaria/sefaria';
+import { INDEX_FIELD_METADATA } from '../constants/fieldMetadata';
+import ModToolsSection from './shared/ModToolsSection';
+import IndexSelector from './shared/IndexSelector';
+import StatusMessage from './shared/StatusMessage';
+
+/**
+ * Detailed help documentation for this tool
+ */
+const HELP_CONTENT = (
+ <>
+ What This Tool Does
+
+ This tool edits Index metadata (text catalog records) across multiple texts.
+ An "Index" in Sefaria is the master record for a text, containing its title, category,
+ authorship, composition date, and structural information.
+
+
+ Unlike the Version Editor which edits translations/editions, this tool edits the
+ underlying text record itself. Use this when you need to update catalog information
+ like descriptions, categories, authors, or commentary relationships.
+
+
+ How It Works
+
+ Search: Enter a version title to find all indices that have versions with that title.
+ Select: Choose which indices to update.
+ Edit: Fill in the metadata fields you want to change.
+ Save: Each index is updated individually via the API, with cache clearing.
+
+
+ Available Fields
+
+
+ Field Description
+
+
+ enDescEnglish description of the text (shown in reader)
+ heDescHebrew description of the text
+ enShortDescBrief English description for search results
+ heShortDescBrief Hebrew description
+ categoriesCategory path in the table of contents (e.g., "Mishnah, Seder Zeraim")
+ authorsAuthor slugs (must exist in AuthorTopic). Comma-separated.
+ compDateComposition date. Single year or range like [1200, 1250]
+ compPlacePlace of composition (English)
+ heCompPlacePlace of composition (Hebrew)
+ pubDatePublication date
+ pubPlacePlace of publication (English)
+ hePubPlacePlace of publication (Hebrew)
+ dependence"Commentary" or "Targum" - marks text as dependent on another
+ base_text_titlesFor commentaries: exact titles of base texts. Comma-separated.
+ collective_titleFor commentaries: the commentary name (e.g., "Rashi")
+ he_collective_titleHebrew collective title (creates a Term if both en/he provided)
+ toc_zoomTable of contents zoom level (0-10, 0=fully expanded)
+
+
+
+ Auto-Detection Feature
+
+ For texts with "X on Y" naming pattern (e.g., "Rashi on Genesis"), you can use
+ 'auto' as a value for certain fields:
+
+
+ dependence: 'auto' - Sets to "Commentary" if pattern detected
+ base_text_titles: 'auto' - Extracts the base text name (e.g., "Genesis")
+ collective_title: 'auto' - Extracts the commentary name (e.g., "Rashi")
+ authors: 'auto' - Looks up the commentary name as an AuthorTopic
+
+
+ Term Creation
+
+ If you provide both collective_title (English) and he_collective_title
+ (Hebrew), the tool will automatically create a Term for that collective title if it
+ doesn't already exist. This is required for the collective title to display properly.
+
+
+
+
Important Notes:
+
+ Authors must exist in the AuthorTopic database. Invalid author names will fail validation.
+ Base text titles must be exact index titles (e.g., "Mishnah Berakhot", not "Mishnah").
+ Categories must match existing category paths in the Sefaria table of contents.
+ Changes trigger a cache reset for each index, which may take a moment.
+ Changes are applied immediately to production . There is no undo.
+
+
+
+ Common Use Cases
+
+ Adding descriptions to a set of related texts
+ Setting up commentary metadata for a new commentary series
+ Moving texts to a different category
+ Adding authorship information to texts by the same author
+ Configuring TOC display depth for complex texts
+
+ >
+);
+
+/**
+ * Detect commentary pattern from title (e.g., "Rashi on Genesis")
+ * Returns { commentaryName, baseText } or null
+ */
+const detectCommentaryPattern = (title) => {
+ const match = title.match(/^(.+?)\s+on\s+(.+)$/);
+ if (match) {
+ return {
+ commentaryName: match[1].trim(),
+ baseText: match[2].trim()
+ };
+ }
+ return null;
+};
+
+/**
+ * Create a term if it doesn't exist
+ */
+const createTermIfNeeded = async (enTitle, heTitle) => {
+ if (!enTitle || !heTitle) {
+ throw new Error("Both English and Hebrew titles are required to create a term");
+ }
+
+ try {
+ // Check if term already exists
+ await Sefaria.apiRequestWithBody(`/api/terms/${encodeURIComponent(enTitle)}`, null, null, 'GET');
+ return true; // Term exists
+ } catch (e) {
+ if (e.message.includes('404') || e.message.includes('not found')) {
+ // Create term
+ const payload = {
+ json: JSON.stringify({
+ name: enTitle,
+ titles: [
+ { lang: "en", text: enTitle, primary: true },
+ { lang: "he", text: heTitle, primary: true }
+ ]
+ })
+ };
+ await Sefaria.apiRequestWithBody(`/api/terms/${encodeURIComponent(enTitle)}`, null, payload);
+ return true;
+ }
+ throw e;
+ }
+};
+
+const BulkIndexEditor = () => {
+ // Search state
+ const [vtitle, setVtitle] = useState("");
+ const [lang, setLang] = useState("");
+ const [searched, setSearched] = useState(false);
+
+ // Results state
+ // indices: Array of {title: string, categories?: string[]} objects
+ const [indices, setIndices] = useState([]);
+ const [pick, setPick] = useState(new Set());
+ const [categories, setCategories] = useState([]);
+
+ // Edit state
+ const [updates, setUpdates] = useState({});
+
+ // UI state
+ const [msg, setMsg] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ // Load categories on mount
+ useEffect(() => {
+ const loadCategories = async () => {
+ try {
+ const data = await Sefaria.apiRequestWithBody('/api/index', null, null, 'GET');
+ const cats = [];
+ const extractCategories = (node, path = []) => {
+ if (node.category) {
+ const fullPath = [...path, node.category].join(", ");
+ cats.push(fullPath);
+ }
+ if (node.contents) {
+ node.contents.forEach(item => {
+ extractCategories(item, node.category ? [...path, node.category] : path);
+ });
+ }
+ };
+ data.forEach(cat => extractCategories(cat));
+ setCategories(cats.sort());
+ } catch (error) {
+ console.error('Failed to load categories:', error);
+ }
+ };
+ loadCategories();
+ }, []);
+
+ /**
+ * Clear search and reset state
+ */
+ const clearSearch = () => {
+ setIndices([]);
+ setPick(new Set());
+ setUpdates({});
+ setMsg("");
+ setSearched(false);
+ };
+
+ /**
+ * Load indices matching the version title
+ */
+ const load = async () => {
+ if (!vtitle.trim()) {
+ setMsg("Please enter a version title");
+ return;
+ }
+
+ setLoading(true);
+ setSearched(true);
+ setMsg("Loading indices...");
+
+ const urlParams = { versionTitle: vtitle };
+ if (lang) {
+ urlParams.language = lang;
+ }
+
+ try {
+ const data = await Sefaria.apiRequestWithBody('/api/version-indices', urlParams, null, 'GET');
+ const resultIndices = data.indices || [];
+ const resultMetadata = data.metadata || {};
+ // Combine indices and metadata into single array of objects
+ const combinedIndices = resultIndices.map(title => ({
+ title,
+ categories: resultMetadata[title]?.categories
+ }));
+ setIndices(combinedIndices);
+ setPick(new Set(resultIndices)); // Set of title strings
+ if (resultIndices.length > 0) {
+ setMsg(`Found ${resultIndices.length} indices with version "${vtitle}"`);
+ } else {
+ setMsg("");
+ }
+ } catch (error) {
+ setMsg(`Error: ${error.message || "Failed to load indices"}`);
+ setIndices([]);
+ setPick(new Set());
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ /**
+ * Handle field value changes
+ */
+ const handleFieldChange = (fieldName, value) => {
+ setUpdates(prev => ({ ...prev, [fieldName]: value }));
+ };
+
+ /**
+ * Save changes to selected indices
+ */
+ const save = async () => {
+ if (!pick.size || !Object.keys(updates).length) return;
+
+ setSaving(true);
+ setMsg("Saving changes...");
+
+ // Process updates to ensure correct data types
+ const processedUpdates = {};
+
+ for (const [field, value] of Object.entries(updates)) {
+ if (!value && field !== "toc_zoom") continue;
+
+ const fieldMeta = INDEX_FIELD_METADATA[field];
+ if (!fieldMeta) {
+ processedUpdates[field] = value;
+ continue;
+ }
+
+ switch (fieldMeta.type) {
+ case 'array':
+ if (value === 'auto') {
+ processedUpdates[field] = 'auto';
+ } else {
+ processedUpdates[field] = value.split(',').map(v => v.trim()).filter(v => v);
+ }
+ break;
+ case 'daterange':
+ if (value.startsWith('[') && value.endsWith(']')) {
+ try {
+ processedUpdates[field] = JSON.parse(value);
+ } catch (e) {
+ setMsg(`Invalid date format for ${field}`);
+ setSaving(false);
+ return;
+ }
+ } else {
+ const year = parseInt(value);
+ if (!isNaN(year)) {
+ processedUpdates[field] = year;
+ } else {
+ setMsg(`Invalid date format for ${field}`);
+ setSaving(false);
+ return;
+ }
+ }
+ break;
+ case 'number':
+ const numValue = parseInt(value);
+ if (isNaN(numValue)) {
+ setMsg(`Invalid number format for ${field}`);
+ setSaving(false);
+ return;
+ }
+ processedUpdates[field] = numValue;
+ break;
+ default:
+ processedUpdates[field] = value;
+ }
+ }
+
+ // Validate authors if present
+ if (processedUpdates.authors && processedUpdates.authors !== 'auto') {
+ try {
+ const authorSlugs = [];
+ for (const authorName of processedUpdates.authors) {
+ const response = await Sefaria.apiRequestWithBody(`/api/name/${authorName}`, null, null, 'GET');
+ const matches = response.completion_objects?.filter(t => t.type === 'AuthorTopic') || [];
+ const exactMatch = matches.find(t => t.title.toLowerCase() === authorName.toLowerCase());
+
+ if (!exactMatch) {
+ const closestMatches = matches.map(t => t.title).slice(0, 3);
+ const msg = matches.length > 0
+ ? `Invalid author "${authorName}". Did you mean: ${closestMatches.join(', ')}?`
+ : `Invalid author "${authorName}". Make sure it exists in the Authors topic.`;
+ setMsg(`Error: ${msg}`);
+ setSaving(false);
+ return;
+ }
+ authorSlugs.push(exactMatch.key);
+ }
+ processedUpdates.authors = authorSlugs;
+ } catch (e) {
+ setMsg(`Error validating authors`);
+ setSaving(false);
+ return;
+ }
+ }
+
+ let successCount = 0;
+ const errors = [];
+
+ for (const indexTitle of pick) {
+ try {
+ setMsg(`Updating ${indexTitle}...`);
+
+ const existingIndexData = await Sefaria.getIndexDetails(indexTitle);
+ if (!existingIndexData) {
+ errors.push(`${indexTitle}: Could not fetch existing index data.`);
+ continue;
+ }
+
+ let indexSpecificUpdates = { ...processedUpdates };
+ const pattern = detectCommentaryPattern(indexTitle);
+
+ // Handle auto-detection for various fields
+ if (indexSpecificUpdates.dependence === 'auto') {
+ indexSpecificUpdates.dependence = pattern ? 'Commentary' : undefined;
+ if (!pattern) delete indexSpecificUpdates.dependence;
+ }
+
+ if (indexSpecificUpdates.base_text_titles === 'auto') {
+ if (pattern?.baseText) {
+ indexSpecificUpdates.base_text_titles = [pattern.baseText];
+ } else {
+ delete indexSpecificUpdates.base_text_titles;
+ }
+ }
+
+ if (indexSpecificUpdates.collective_title === 'auto') {
+ if (pattern?.commentaryName) {
+ indexSpecificUpdates.collective_title = pattern.commentaryName;
+ } else {
+ delete indexSpecificUpdates.collective_title;
+ }
+ }
+
+ // Handle term creation for collective_title
+ if (indexSpecificUpdates.collective_title && indexSpecificUpdates.he_collective_title) {
+ try {
+ await createTermIfNeeded(indexSpecificUpdates.collective_title, indexSpecificUpdates.he_collective_title);
+ delete indexSpecificUpdates.he_collective_title;
+ } catch (e) {
+ errors.push(`${indexTitle}: Failed to create term: ${e.message}`);
+ continue;
+ }
+ }
+
+ // Handle authors auto-detection
+ if (indexSpecificUpdates.authors === 'auto' && pattern?.commentaryName) {
+ try {
+ const response = await Sefaria.apiRequestWithBody(`/api/name/${pattern.commentaryName}`, null, null, 'GET');
+ const matches = response.completion_objects?.filter(t => t.type === 'AuthorTopic') || [];
+ const exactMatch = matches.find(t => t.title.toLowerCase() === pattern.commentaryName.toLowerCase());
+ if (exactMatch) {
+ indexSpecificUpdates.authors = [exactMatch.key];
+ } else {
+ delete indexSpecificUpdates.authors;
+ }
+ } catch (e) {
+ delete indexSpecificUpdates.authors;
+ }
+ }
+
+ // Handle TOC zoom
+ let tocZoomValue = null;
+ if ('toc_zoom' in indexSpecificUpdates) {
+ tocZoomValue = indexSpecificUpdates.toc_zoom;
+ delete indexSpecificUpdates.toc_zoom;
+
+ if (existingIndexData.schema?.nodes) {
+ existingIndexData.schema.nodes.forEach(node => {
+ if (node.nodeType === "JaggedArrayNode") {
+ node.toc_zoom = tocZoomValue;
+ }
+ });
+ } else if (existingIndexData.schema) {
+ existingIndexData.schema.toc_zoom = tocZoomValue;
+ }
+ }
+
+ const postData = {
+ title: indexTitle,
+ heTitle: existingIndexData.heTitle,
+ categories: existingIndexData.categories,
+ schema: existingIndexData.schema,
+ ...indexSpecificUpdates
+ };
+
+ const urlParams = { update: '1' };
+ const indexPath = encodeURIComponent(indexTitle.replace(/ /g, "_"));
+ const payload = { json: JSON.stringify(postData) };
+
+ // Update index via raw API
+ await Sefaria.apiRequestWithBody(`/api/v2/raw/index/${indexPath}`, urlParams, payload);
+
+ // Clear caches (non-JSON endpoint)
+ const resetResponse = await fetch(`/admin/reset/${encodeURIComponent(indexTitle)}`, {
+ method: 'GET',
+ credentials: 'same-origin'
+ });
+ if (!resetResponse.ok) {
+ throw new Error("Failed to reset cache");
+ }
+
+ successCount++;
+ } catch (e) {
+ const errorMsg = e.message || 'Unknown error';
+ errors.push(`${indexTitle}: ${errorMsg}`);
+ }
+ }
+
+ if (errors.length) {
+ setMsg(`Updated ${successCount} of ${pick.size} indices. Errors: ${errors.join('; ')}`);
+ } else {
+ setMsg(`Successfully updated ${successCount} indices`);
+ setUpdates({});
+ }
+ setSaving(false);
+ };
+
+ /**
+ * Render a field input based on its metadata
+ */
+ const renderField = (fieldName) => {
+ const fieldMeta = INDEX_FIELD_METADATA[fieldName];
+ const currentValue = updates[fieldName] || "";
+
+ const commonProps = {
+ className: "dlVersionSelect fieldInput",
+ placeholder: fieldMeta.placeholder,
+ value: currentValue,
+ onChange: e => handleFieldChange(fieldName, e.target.value),
+ style: { width: "100%", direction: fieldMeta.dir || "ltr" }
+ };
+
+ return (
+
+
{fieldMeta.label}:
+
+ {fieldMeta.help && (
+
+ {fieldMeta.help}
+ {fieldMeta.auto && (
+ (Supports 'auto' for commentary texts)
+ )}
+
+ )}
+
+ {fieldName === "categories" ? (
+
+ Select category...
+ {categories.map(cat => (
+ {cat}
+ ))}
+
+ ) : fieldMeta.type === "select" && fieldMeta.options ? (
+
+ {fieldMeta.options.map(option => (
+ {option.label}
+ ))}
+
+ ) : fieldMeta.type === "textarea" ? (
+
+ ) : fieldMeta.type === "number" ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+
+ // Check if there are actual changes - toc_zoom of 0 is valid, so check for undefined instead
+ const hasChanges = Object.keys(updates).filter(k => updates[k] || (k === 'toc_zoom' && updates[k] !== undefined && updates[k] !== '')).length > 0;
+
+ return (
+
+ {/* Warning box */}
+
+
Important Notes:
+
+ Authors: Must exist in the Authors topic. Use exact names or slugs.
+ Collective Title: If Hebrew equivalent is provided, terms will be created automatically.
+ Base Text Titles: Must be exact index titles (e.g., "Mishnah Berakhot").
+ Auto-detection: Works for commentary texts with "X on Y" format.
+ TOC Zoom: Integer 0-10 (0=fully expanded).
+
+
+
+ {/* Search bar */}
+
+ setVtitle(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && load()}
+ />
+
+ {loading ? <> Searching...> : "Find Indices"}
+
+
+
+ {/* Language filter - inline */}
+
+ Filter by language:
+ setLang(e.target.value)}
+ >
+ All languages
+ Hebrew only
+ English only
+
+
+
+ {/* Clear button - centered */}
+ {searched && (
+
+
+ Clear Search
+
+
+ )}
+
+ {/* No results message */}
+ {searched && !loading && indices.length === 0 && (
+
+ No indices found with version "{vtitle}"
+ Please verify the exact version title. Version titles are case-sensitive
+ and must match exactly (e.g., "Torat Emet 357" not "torat emet").
+
+ )}
+
+ {/* Index selector */}
+ {indices.length > 0 && (
+
+ )}
+
+ {/* Field inputs */}
+ {pick.size > 0 && (
+ <>
+
+ Edit fields for {pick.size} selected {pick.size === 1 ? 'index' : 'indices'}:
+
+
+
+ {Object.keys(INDEX_FIELD_METADATA).map(f => renderField(f))}
+
+
+ {hasChanges && (
+
+
Changes to apply:
+
+ {Object.entries(updates).filter(([k, v]) => v || k === 'toc_zoom').map(([k, v]) => (
+ {INDEX_FIELD_METADATA[k]?.label || k}: "{v}"
+ ))}
+
+
+ )}
+
+
+
+ {saving ? <> Saving...> : `Save Changes to ${pick.size} Indices`}
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default BulkIndexEditor;
diff --git a/static/js/modtools/components/NodeTitleEditor.jsx b/static/js/modtools/components/NodeTitleEditor.jsx
new file mode 100644
index 0000000000..9271b584ce
--- /dev/null
+++ b/static/js/modtools/components/NodeTitleEditor.jsx
@@ -0,0 +1,501 @@
+/**
+ * NodeTitleEditor - Edit node titles within an Index schema
+ *
+ * NOTE: This component is currently DISABLED in ModeratorToolsPanel.
+ * It is retained for future re-enablement but not rendered in the UI.
+ *
+ * Allows editing English and Hebrew titles for individual nodes in a text's
+ * schema structure. Useful for fixing title errors or adding missing translations.
+ *
+ * Workflow:
+ * 1. User enters an index title to load
+ * 2. Tool displays all schema nodes with their current titles
+ * 3. User edits titles as needed
+ * 4. On save, tool updates the entire Index with modified schema
+ *
+ * Validation:
+ * - English titles must be ASCII only
+ * - No periods, hyphens, colons, or slashes allowed in titles
+ * - Hebrew titles can be changed freely
+ *
+ * Backend API: POST /api/v2/raw/index/{title}
+ *
+ * Documentation:
+ * - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
+ * - See /docs/modtools/COMPONENT_LOGIC.md for detailed implementation logic
+ */
+import React, { useState } from 'react';
+import $ from '../../sefaria/sefariaJquery';
+import Sefaria from '../../sefaria/sefaria';
+import ModToolsSection from './shared/ModToolsSection';
+import StatusMessage from './shared/StatusMessage';
+
+/**
+ * Detailed help documentation for this tool
+ */
+const HELP_CONTENT = (
+ <>
+ What This Tool Does
+
+ This tool edits node titles within a text's schema structure.
+ Every text in Sefaria has a schema that defines its structure (chapters, sections, etc.).
+ Each structural unit is a "node" with English and Hebrew titles.
+
+
+ Use this tool when you need to fix typos in section names, add missing Hebrew titles,
+ or update how a text's internal structure is displayed.
+
+
+ How It Works
+
+ Load: Enter the exact index title (e.g., "Mishneh Torah, Laws of Kings").
+ Review: All nodes in the schema are displayed with their current titles.
+ Edit: Modify English or Hebrew titles as needed.
+ Save: The entire Index is saved with the updated schema.
+
+
+ What Are Nodes?
+
+ Nodes are the structural building blocks of a text's schema. For example:
+
+
+ Mishneh Torah has nodes for each "Laws of X" section
+ Shulchan Arukh has nodes for Orach Chaim, Yoreh De'ah, etc.
+ Complex texts may have nested nodes (sections within sections)
+
+
+ The "Path" shown for each node (e.g., nodes[0][1][2]) indicates its
+ position in the nested structure.
+
+
+ Shared Titles
+
+ Some nodes use a "shared title" (Term) instead of direct title strings. This allows
+ the same title to be reused across different texts. If a node has a shared title,
+ you'll see it displayed with an option to remove it.
+
+
+ When you remove a shared title, the node will use its direct title
+ and heTitle fields instead. This is useful when a text uses a generic
+ term but needs a more specific title.
+
+
+ Validation Rules
+
+
+ Field Rules
+
+
+
+ English titles
+
+ Must be ASCII characters only. Cannot contain: periods (.), hyphens (-),
+ colons (:), forward slashes (/), or backslashes (\).
+
+
+
+ Hebrew titles
+ No restrictions. Can contain any Unicode characters.
+
+
+
+
+ Dependency Checking
+
+ Before you edit, the tool checks what depends on this index:
+
+
+ Dependent texts: Commentaries or other texts that reference this one
+ Versions: Translations and editions of this text
+ Links: Connections to other texts in the library
+
+
+ A warning is shown if dependencies exist. Changing node titles on texts with many
+ dependencies should be done carefully, as it may affect references.
+
+
+
+
Important Notes:
+
+ English title restrictions are enforced because titles become part of reference URLs.
+ Changing titles does not automatically update existing references or links.
+ The tool saves the entire Index , not just the changed nodes.
+ A cache reset is triggered after saving, which may take a moment.
+ Changes are applied immediately to production . There is no undo.
+
+
+
+ Common Use Cases
+
+ Fixing typos in section or chapter names
+ Adding missing Hebrew titles to nodes
+ Standardizing title formats across similar texts
+ Removing shared titles when a text needs custom naming
+ Updating outdated or incorrect transliterations
+
+
+ Troubleshooting
+
+ "Invalid: ASCII only" - English title contains non-ASCII characters. Remove accents or special characters.
+ "contains forbidden characters" - Title has periods, hyphens, colons, or slashes. Use spaces or other characters instead.
+ Changes not visible - Clear your browser cache or wait a few minutes for the cache reset to propagate.
+
+ >
+);
+
+const NodeTitleEditor = () => {
+ // Index state
+ const [indexTitle, setIndexTitle] = useState("");
+ const [indexData, setIndexData] = useState(null);
+
+ // Edit state
+ const [editingNodes, setEditingNodes] = useState({});
+
+ // Dependency state
+ const [dependencies, setDependencies] = useState(null);
+ const [checkingDeps, setCheckingDeps] = useState(false);
+
+ // UI state
+ const [msg, setMsg] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ /**
+ * Check what depends on this index
+ */
+ const checkDependencies = async (title) => {
+ setCheckingDeps(true);
+ try {
+ const response = await $.get(`/api/check-index-dependencies/${encodeURIComponent(title)}`);
+ setDependencies(response);
+ } catch (e) {
+ console.error('Failed to check dependencies:', e);
+ setDependencies(null);
+ }
+ setCheckingDeps(false);
+ };
+
+ /**
+ * Load the index and extract nodes
+ */
+ const load = async () => {
+ if (!indexTitle.trim()) {
+ setMsg("❌ Please enter an index title");
+ return;
+ }
+
+ setLoading(true);
+ setMsg("Loading index...");
+
+ try {
+ const data = await Sefaria.getIndexDetails(indexTitle);
+ setIndexData(data);
+ setEditingNodes({});
+ setMsg(`✅ Loaded ${indexTitle}`);
+ await checkDependencies(indexTitle);
+ } catch (e) {
+ setMsg(`❌ Error: Could not load index "${indexTitle}"`);
+ setIndexData(null);
+ setDependencies(null);
+ }
+ setLoading(false);
+ };
+
+ /**
+ * Extract nodes from schema recursively
+ */
+ const extractNodes = (schema, path = []) => {
+ let nodes = [];
+
+ if (schema.nodes) {
+ schema.nodes.forEach((node, index) => {
+ const nodePath = [...path, index];
+ nodes.push({
+ path: nodePath,
+ pathStr: nodePath.join('.'),
+ node: node,
+ titles: node.titles || [],
+ sharedTitle: node.sharedTitle,
+ title: node.title,
+ heTitle: node.heTitle
+ });
+
+ // Recursively get child nodes
+ if (node.nodes) {
+ nodes = nodes.concat(extractNodes(node, nodePath));
+ }
+ });
+ }
+
+ return nodes;
+ };
+
+ /**
+ * Handle node edit
+ */
+ const handleNodeEdit = (pathStr, field, value) => {
+ setEditingNodes(prev => ({
+ ...prev,
+ [pathStr]: {
+ ...prev[pathStr],
+ [field]: value,
+ // Add validation for English titles
+ [`${field}_valid`]: field === 'title'
+ ? (value.match(/^[\x00-\x7F]*$/) && !value.match(/[:.\\/-]/))
+ : true
+ }
+ }));
+ };
+
+ /**
+ * Save changes
+ */
+ const save = async () => {
+ if (Object.keys(editingNodes).length === 0) {
+ setMsg("❌ No changes to save");
+ return;
+ }
+
+ setSaving(true);
+ setMsg("Saving changes...");
+
+ try {
+ // Validate changes
+ const validationErrors = [];
+ Object.entries(editingNodes).forEach(([, edits]) => {
+ if (edits.title !== undefined) {
+ if (!edits.title.match(/^[\x00-\x7F]*$/)) {
+ validationErrors.push(`English title "${edits.title}" contains non-ASCII characters`);
+ }
+ if (edits.title.match(/[:.\\/-]/)) {
+ validationErrors.push(`English title "${edits.title}" contains forbidden characters`);
+ }
+ }
+ });
+
+ if (validationErrors.length > 0) {
+ setMsg(`❌ Validation errors: ${validationErrors.join('; ')}`);
+ setSaving(false);
+ return;
+ }
+
+ // Create deep copy
+ const updatedIndex = JSON.parse(JSON.stringify(indexData));
+
+ // Apply edits
+ Object.entries(editingNodes).forEach(([pathStr, edits]) => {
+ const path = pathStr.split('.').map(Number);
+ let node = updatedIndex.schema || updatedIndex;
+
+ for (let i = 0; i < path.length; i++) {
+ if (i === path.length - 1) {
+ const targetNode = node.nodes[path[i]];
+
+ if (edits.removeSharedTitle) {
+ delete targetNode.sharedTitle;
+ }
+
+ if (edits.title !== undefined) {
+ targetNode.title = edits.title;
+ const enTitleIndex = targetNode.titles?.findIndex(t => t.lang === "en" && t.primary);
+ if (enTitleIndex >= 0) {
+ targetNode.titles[enTitleIndex].text = edits.title;
+ } else {
+ if (!targetNode.titles) targetNode.titles = [];
+ targetNode.titles.push({ text: edits.title, lang: "en", primary: true });
+ }
+ }
+
+ if (edits.heTitle !== undefined) {
+ targetNode.heTitle = edits.heTitle;
+ const heTitleIndex = targetNode.titles?.findIndex(t => t.lang === "he" && t.primary);
+ if (heTitleIndex >= 0) {
+ targetNode.titles[heTitleIndex].text = edits.heTitle;
+ } else {
+ if (!targetNode.titles) targetNode.titles = [];
+ targetNode.titles.push({ text: edits.heTitle, lang: "he", primary: true });
+ }
+ }
+ } else {
+ node = node.nodes[path[i]];
+ }
+ }
+ });
+
+ // Save
+ const url = `/api/v2/raw/index/${encodeURIComponent(indexTitle.replace(/ /g, "_"))}`;
+ await $.ajax({
+ url,
+ type: 'POST',
+ data: { json: JSON.stringify(updatedIndex) },
+ dataType: 'json'
+ });
+
+ await $.get(`/admin/reset/${encodeURIComponent(indexTitle)}`);
+
+ setMsg(`✅ Successfully updated node titles`);
+ setEditingNodes({});
+
+ // Reload
+ setTimeout(() => load(), 1000);
+ } catch (e) {
+ let errorMsg = e.responseJSON?.error || e.responseText || 'Unknown error';
+ setMsg(`❌ Error: ${errorMsg}`);
+ }
+
+ setSaving(false);
+ };
+
+ const nodes = indexData ? extractNodes(indexData.schema || indexData) : [];
+ // Check for validation errors - title_valid must be explicitly true to pass
+ const hasValidationErrors = Object.values(editingNodes).some(edits => edits.title !== undefined && edits.title_valid !== true);
+ const hasChanges = Object.keys(editingNodes).length > 0;
+
+ return (
+
+ {/* Search bar */}
+
+ setIndexTitle(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && load()}
+ />
+
+ {loading ? <> Loading...> : "Load Index"}
+
+
+
+ {nodes.length > 0 && (
+ <>
+
+ Found {nodes.length} nodes. Edit titles below:
+
+
+ {/* Validation warning */}
+
+
Important:
+
+ English titles: ASCII only. No periods, hyphens, colons, slashes.
+ Hebrew titles: Can be changed freely.
+ Main index titles: May affect dependent texts.
+
+
+
+ {/* Dependency warning */}
+ {dependencies?.has_dependencies && (
+
+
Dependency Warning for "{indexTitle}":
+
+ {dependencies.dependent_count > 0 && (
+ {dependencies.dependent_count} dependent texts: {" "}
+ {dependencies.dependent_indices.slice(0, 5).join(', ')}
+ {dependencies.dependent_indices.length > 5 ? '...' : ''}
+
+ )}
+ {dependencies.version_count > 0 && (
+ {dependencies.version_count} versions reference this index
+ )}
+ {dependencies.link_count > 0 && (
+ {dependencies.link_count} links reference this text
+ )}
+
+
+ )}
+
+ {checkingDeps && (
+ Checking dependencies...
+ )}
+
+ {/* Node list */}
+
+ {nodes.map(({ path, pathStr, node, sharedTitle, title, heTitle }) => {
+ const edits = editingNodes[pathStr] || {};
+ const nodeHasChanges = edits.title !== undefined || edits.heTitle !== undefined || edits.removeSharedTitle;
+
+ return (
+
+ {sharedTitle && (
+
+ Shared Title: "{sharedTitle}"
+
+ handleNodeEdit(pathStr, 'removeSharedTitle', e.target.checked)}
+ />
+ Remove shared title
+
+
+ )}
+
+
+
+
English Title:
+
handleNodeEdit(pathStr, 'title', e.target.value)}
+ />
+ {edits.title !== undefined && edits.title_valid === false && (
+
+ Invalid: ASCII only, no special characters
+
+ )}
+
+
+
Hebrew Title:
+
handleNodeEdit(pathStr, 'heTitle', e.target.value)}
+ style={{ direction: "rtl" }}
+ />
+
+
+
+ {node.nodeType && (
+
+ Type: {node.nodeType} | Path: nodes[{path.join('][')}]
+
+ )}
+
+ );
+ })}
+
+
+ {hasChanges && (
+
+
+ {saving ? <> Saving...> :
+ hasValidationErrors ? "Fix validation errors to save" :
+ `Save Changes to ${Object.keys(editingNodes).length} Nodes`}
+
+
+ )}
+ >
+ )}
+
+
+
+ );
+};
+
+export default NodeTitleEditor;
diff --git a/static/js/modtools/components/shared/index.js b/static/js/modtools/components/shared/index.js
index 847b4b0533..f979d36ee2 100644
--- a/static/js/modtools/components/shared/index.js
+++ b/static/js/modtools/components/shared/index.js
@@ -1,9 +1,22 @@
/**
- * Shared components for ModeratorToolsPanel
+ * Shared components barrel export
*
- * See docs/modtools/MODTOOLS_GUIDE.md for full documentation.
+ * These components provide consistent UI patterns across all modtools.
*/
export { default as ModToolsSection } from './ModToolsSection';
-export { default as HelpButton } from './HelpButton';
-export { default as StatusMessage, MESSAGE_TYPES } from './StatusMessage';
+export { default as StatusMessage } from './StatusMessage';
export { default as IndexSelector } from './IndexSelector';
+export { default as HelpButton } from './HelpButton';
+
+// Utility function for safe HTML text extraction (re-exported for convenience)
+export const stripHtmlTags = (text) => {
+ if (!text) return '';
+ return text
+ .replace(/<[^>]*>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .trim();
+};
diff --git a/static/js/modtools/index.js b/static/js/modtools/index.js
index f84fcd116a..d94c56b866 100644
--- a/static/js/modtools/index.js
+++ b/static/js/modtools/index.js
@@ -1,11 +1,33 @@
/**
- * ModTools module exports
+ * ModTools Module - Main entry point
*
- * This module provides components for the Moderator Tools panel.
- * See docs/modtools/MODTOOLS_GUIDE.md for full documentation.
+ * This module contains all moderator tools for the Sefaria admin interface.
+ * Access at /modtools (requires staff permissions).
+ *
+ * Components:
+ * - BulkDownloadText: Download text versions in bulk by pattern matching
+ * - BulkUploadCSV: Upload text content from CSV files
+ * - WorkflowyModeratorTool: Upload Workflowy OPML exports
+ * - UploadLinksFromCSV: Create links between refs from CSV
+ * - DownloadLinks: Download links as CSV
+ * - RemoveLinksFromCsv: Delete links from CSV
+ * - BulkVersionEditor: Edit Version metadata across multiple indices
+ *
+ * NOTE: The following components are temporarily disabled (open tickets to reintroduce):
+ * - BulkIndexEditor: Edit Index metadata across multiple indices
+ * - AutoLinkCommentaryTool: Create links between commentaries and base texts
+ * - NodeTitleEditor: Edit node titles within an Index schema
+ *
+ * For AI agents:
+ * - See /docs/modtools/AI_AGENT_GUIDE.md for detailed documentation
+ * - Constants in ./constants/fieldMetadata.js define editable fields
+ * - Shared components in ./components/shared/ provide consistent UI
*/
-// Extracted tool components
+// Main container (legacy, still in parent directory)
+export { default as ModeratorToolsPanel } from '../ModeratorToolsPanel';
+
+// Individual components
export { default as BulkDownloadText } from './components/BulkDownloadText';
export { default as BulkUploadCSV } from './components/BulkUploadCSV';
export { default as WorkflowyModeratorTool } from './components/WorkflowyModeratorTool';
@@ -14,11 +36,16 @@ export { default as DownloadLinks } from './components/DownloadLinks';
export { default as RemoveLinksFromCsv } from './components/RemoveLinksFromCsv';
export { default as BulkVersionEditor } from './components/BulkVersionEditor';
-// Shared UI components
-export {
- ModToolsSection,
- HelpButton,
- StatusMessage,
- MESSAGE_TYPES,
- IndexSelector
-} from './components/shared';
+// TODO: The following exports are temporarily disabled - open tickets to reintroduce:
+// export { default as BulkIndexEditor } from './components/BulkIndexEditor';
+// export { default as AutoLinkCommentaryTool } from './components/AutoLinkCommentaryTool';
+// export { default as NodeTitleEditor } from './components/NodeTitleEditor';
+
+// Shared components
+export * from './components/shared';
+
+// Constants
+export * from './constants/fieldMetadata';
+
+// Utils
+export * from './utils';
From 191e1c65d1349cf020f2ecf5100da8037004c1cc Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Sun, 11 Jan 2026 13:58:13 +0200
Subject: [PATCH 12/26] docs: Add note about index_post changes needed for
disabled tools
---
static/js/ModeratorToolsPanel.jsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/static/js/ModeratorToolsPanel.jsx b/static/js/ModeratorToolsPanel.jsx
index 179283ef7d..ea8f1ce50d 100644
--- a/static/js/ModeratorToolsPanel.jsx
+++ b/static/js/ModeratorToolsPanel.jsx
@@ -38,6 +38,9 @@ import BulkVersionEditor from './modtools/components/BulkVersionEditor';
// - BulkIndexEditor: Bulk edit index metadata
// - AutoLinkCommentaryTool: Auto-link commentaries to base texts
// - NodeTitleEditor: Edit node titles within an Index schema
+// When re-enabling NodeTitleEditor/BulkIndexEditor, the index_post function in reader/views.py
+// will need enhanced error handling (dependency checks, categorized error responses).
+// See PR #2984 review comments for context.
// import BulkIndexEditor from './modtools/components/BulkIndexEditor';
// import AutoLinkCommentaryTool from './modtools/components/AutoLinkCommentaryTool';
// import NodeTitleEditor from './modtools/components/NodeTitleEditor';
From 03031d406364498e65dd21eac5aea72dc52f4e85 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Wed, 14 Jan 2026 13:19:56 +0200
Subject: [PATCH 13/26] refactor(modtools): Use Sefaria design system colors
Replace custom color palette with Sefaria CSS variables from s2.css
for consistency with the rest of the application.
Co-Authored-By: Claude Opus 4.5
---
static/css/modtools.css | 46 ++++++++++++++++++++---------------------
1 file changed, 23 insertions(+), 23 deletions(-)
diff --git a/static/css/modtools.css b/static/css/modtools.css
index 847088410e..45e04626d8 100644
--- a/static/css/modtools.css
+++ b/static/css/modtools.css
@@ -29,34 +29,34 @@
:root {
/*
* COLOR PALETTE
- * Warm scholarly tones with clear semantic meaning
+ * Using Sefaria design system colors from s2.css
*/
/* Background colors */
- --mt-bg-page: #F8F7F4; /* Main page background */
- --mt-bg-card: #FFFFFF; /* Card/section background */
- --mt-bg-subtle: #F3F2EE; /* Subtle background for groupings */
- --mt-bg-input: #FAFAF8; /* Input field background */
- --mt-bg-hover: #F0EFEB; /* Hover state background */
+ --mt-bg-page: var(--lightest-grey); /* Main page background */
+ --mt-bg-card: #FFFFFF; /* Card/section background */
+ --mt-bg-subtle: var(--lighter-grey); /* Subtle background for groupings */
+ --mt-bg-input: var(--lightest-grey); /* Input field background */
+ --mt-bg-hover: var(--lighter-grey); /* Hover state background */
/* Brand colors */
- --mt-primary: #1E3A5F; /* Primary actions, headings */
- --mt-primary-hover: #152942; /* Primary hover state */
- --mt-primary-light: rgba(30, 58, 95, 0.08); /* Primary tint for backgrounds */
- --mt-accent: #0891B2; /* Accent/links */
- --mt-accent-hover: #0E7490; /* Accent hover */
- --mt-accent-light: rgba(8, 145, 178, 0.1); /* Accent tint */
+ --mt-primary: var(--sefaria-blue); /* Primary actions, headings */
+ --mt-primary-hover: #122B4A; /* Primary hover state (darker sefaria-blue) */
+ --mt-primary-light: rgba(24, 52, 93, 0.08); /* Primary tint for backgrounds */
+ --mt-accent: var(--inline-link-blue); /* Accent/links */
+ --mt-accent-hover: #3A5FA6; /* Accent hover */
+ --mt-accent-light: rgba(72, 113, 191, 0.1); /* Accent tint */
/* Text colors */
- --mt-text: #1A1A1A; /* Primary text */
- --mt-text-secondary: #5C5C5C; /* Secondary/supporting text */
- --mt-text-muted: #8B8B8B; /* Muted/placeholder text */
- --mt-text-on-primary: #FFFFFF; /* Text on primary color */
+ --mt-text: var(--darkest-grey); /* Primary text */
+ --mt-text-secondary: var(--dark-grey); /* Secondary/supporting text */
+ --mt-text-muted: var(--medium-grey); /* Muted/placeholder text */
+ --mt-text-on-primary: #FFFFFF; /* Text on primary color */
/* Border colors */
- --mt-border: #E5E3DD; /* Default border */
- --mt-border-hover: #C5C3BC; /* Border on hover */
- --mt-border-focus: var(--mt-primary); /* Border on focus */
+ --mt-border: var(--lighter-grey); /* Default border */
+ --mt-border-hover: var(--light-grey); /* Border on hover */
+ --mt-border-focus: var(--mt-primary); /* Border on focus */
/* Status colors - Success */
--mt-success: #059669;
@@ -324,7 +324,7 @@
-moz-appearance: none;
appearance: none;
background-color: var(--mt-bg-input);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%231E3A5F' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2318345D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 14px 14px;
@@ -831,7 +831,7 @@
}
.modTools .indexListRow.selected {
- background: rgba(30, 58, 95, 0.08);
+ background: rgba(24, 52, 93, 0.08);
box-shadow: inset 3px 0 0 var(--mt-primary);
}
@@ -1135,8 +1135,8 @@
.modTools .changesPreview {
padding: var(--mt-space-md) var(--mt-space-lg);
margin-bottom: var(--mt-space-lg);
- background: linear-gradient(135deg, rgba(8, 145, 178, 0.08) 0%, rgba(30, 58, 95, 0.06) 100%);
- border: 1px solid rgba(8, 145, 178, 0.3);
+ background: linear-gradient(135deg, rgba(72, 113, 191, 0.08) 0%, rgba(24, 52, 93, 0.06) 100%);
+ border: 1px solid rgba(72, 113, 191, 0.3);
border-radius: var(--mt-radius-md);
font-size: 14px;
}
From 49be433a05229f6e6c766b4e8dd7a0b3b71f01d4 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Wed, 14 Jan 2026 13:24:50 +0200
Subject: [PATCH 14/26] refactor(modtools): Use Sefaria design system colors
Replace custom color palette with Sefaria CSS variables from s2.css
for consistency with the rest of the application.
Co-Authored-By: Claude Opus 4.5
---
static/css/modtools.css | 46 ++++++++++++++++++++---------------------
1 file changed, 23 insertions(+), 23 deletions(-)
diff --git a/static/css/modtools.css b/static/css/modtools.css
index 847088410e..45e04626d8 100644
--- a/static/css/modtools.css
+++ b/static/css/modtools.css
@@ -29,34 +29,34 @@
:root {
/*
* COLOR PALETTE
- * Warm scholarly tones with clear semantic meaning
+ * Using Sefaria design system colors from s2.css
*/
/* Background colors */
- --mt-bg-page: #F8F7F4; /* Main page background */
- --mt-bg-card: #FFFFFF; /* Card/section background */
- --mt-bg-subtle: #F3F2EE; /* Subtle background for groupings */
- --mt-bg-input: #FAFAF8; /* Input field background */
- --mt-bg-hover: #F0EFEB; /* Hover state background */
+ --mt-bg-page: var(--lightest-grey); /* Main page background */
+ --mt-bg-card: #FFFFFF; /* Card/section background */
+ --mt-bg-subtle: var(--lighter-grey); /* Subtle background for groupings */
+ --mt-bg-input: var(--lightest-grey); /* Input field background */
+ --mt-bg-hover: var(--lighter-grey); /* Hover state background */
/* Brand colors */
- --mt-primary: #1E3A5F; /* Primary actions, headings */
- --mt-primary-hover: #152942; /* Primary hover state */
- --mt-primary-light: rgba(30, 58, 95, 0.08); /* Primary tint for backgrounds */
- --mt-accent: #0891B2; /* Accent/links */
- --mt-accent-hover: #0E7490; /* Accent hover */
- --mt-accent-light: rgba(8, 145, 178, 0.1); /* Accent tint */
+ --mt-primary: var(--sefaria-blue); /* Primary actions, headings */
+ --mt-primary-hover: #122B4A; /* Primary hover state (darker sefaria-blue) */
+ --mt-primary-light: rgba(24, 52, 93, 0.08); /* Primary tint for backgrounds */
+ --mt-accent: var(--inline-link-blue); /* Accent/links */
+ --mt-accent-hover: #3A5FA6; /* Accent hover */
+ --mt-accent-light: rgba(72, 113, 191, 0.1); /* Accent tint */
/* Text colors */
- --mt-text: #1A1A1A; /* Primary text */
- --mt-text-secondary: #5C5C5C; /* Secondary/supporting text */
- --mt-text-muted: #8B8B8B; /* Muted/placeholder text */
- --mt-text-on-primary: #FFFFFF; /* Text on primary color */
+ --mt-text: var(--darkest-grey); /* Primary text */
+ --mt-text-secondary: var(--dark-grey); /* Secondary/supporting text */
+ --mt-text-muted: var(--medium-grey); /* Muted/placeholder text */
+ --mt-text-on-primary: #FFFFFF; /* Text on primary color */
/* Border colors */
- --mt-border: #E5E3DD; /* Default border */
- --mt-border-hover: #C5C3BC; /* Border on hover */
- --mt-border-focus: var(--mt-primary); /* Border on focus */
+ --mt-border: var(--lighter-grey); /* Default border */
+ --mt-border-hover: var(--light-grey); /* Border on hover */
+ --mt-border-focus: var(--mt-primary); /* Border on focus */
/* Status colors - Success */
--mt-success: #059669;
@@ -324,7 +324,7 @@
-moz-appearance: none;
appearance: none;
background-color: var(--mt-bg-input);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%231E3A5F' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2318345D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 14px 14px;
@@ -831,7 +831,7 @@
}
.modTools .indexListRow.selected {
- background: rgba(30, 58, 95, 0.08);
+ background: rgba(24, 52, 93, 0.08);
box-shadow: inset 3px 0 0 var(--mt-primary);
}
@@ -1135,8 +1135,8 @@
.modTools .changesPreview {
padding: var(--mt-space-md) var(--mt-space-lg);
margin-bottom: var(--mt-space-lg);
- background: linear-gradient(135deg, rgba(8, 145, 178, 0.08) 0%, rgba(30, 58, 95, 0.06) 100%);
- border: 1px solid rgba(8, 145, 178, 0.3);
+ background: linear-gradient(135deg, rgba(72, 113, 191, 0.08) 0%, rgba(24, 52, 93, 0.06) 100%);
+ border: 1px solid rgba(72, 113, 191, 0.3);
border-radius: var(--mt-radius-md);
font-size: 14px;
}
From b8f513e317271515ee5925552ebffc77530fe0c0 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Wed, 14 Jan 2026 13:30:05 +0200
Subject: [PATCH 15/26] refactor(modtools): Use existing chevron-down.svg
instead of inline component
Replaces the inline ChevronIcon component with an img tag referencing
the existing chevron-down.svg icon file, following the established
pattern for icon usage in the codebase.
Co-Authored-By: Claude Opus 4.5
---
static/css/modtools.css | 4 ++--
.../components/shared/ModToolsSection.jsx | 18 +-----------------
2 files changed, 3 insertions(+), 19 deletions(-)
diff --git a/static/css/modtools.css b/static/css/modtools.css
index 45e04626d8..0bc45026ed 100644
--- a/static/css/modtools.css
+++ b/static/css/modtools.css
@@ -1351,14 +1351,14 @@
color: var(--mt-primary);
}
-.modTools .collapseToggle svg {
+.modTools .collapseToggle img {
width: 14px;
height: 14px;
transition: transform var(--mt-transition);
}
/* Collapsed state */
-.modTools .modToolsSection.collapsed .collapseToggle svg {
+.modTools .modToolsSection.collapsed .collapseToggle img {
transform: rotate(-90deg);
}
diff --git a/static/js/modtools/components/shared/ModToolsSection.jsx b/static/js/modtools/components/shared/ModToolsSection.jsx
index 6ba5c6e166..4542ef6410 100644
--- a/static/js/modtools/components/shared/ModToolsSection.jsx
+++ b/static/js/modtools/components/shared/ModToolsSection.jsx
@@ -27,22 +27,6 @@ import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import HelpButton from './HelpButton';
-/**
- * Chevron icon component for collapse indicator
- */
-const ChevronIcon = () => (
-
-
-
-);
-
/**
* ModToolsSection component
*
@@ -100,7 +84,7 @@ const ModToolsSection = ({
>
-
+
{title &&
{title} }
From d149ecaf36c0e4d26e8960dbbed847cefb767e5f Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Wed, 14 Jan 2026 13:33:58 +0200
Subject: [PATCH 16/26] refactor(modtools): Use existing chevron-down.svg
instead of inline component
Replaces the inline ChevronIcon component with an img tag referencing
the existing chevron-down.svg icon file, following the established
pattern for icon usage in the codebase.
Co-Authored-By: Claude Opus 4.5
---
static/css/modtools.css | 4 ++--
.../components/shared/ModToolsSection.jsx | 18 +-----------------
2 files changed, 3 insertions(+), 19 deletions(-)
diff --git a/static/css/modtools.css b/static/css/modtools.css
index 45e04626d8..0bc45026ed 100644
--- a/static/css/modtools.css
+++ b/static/css/modtools.css
@@ -1351,14 +1351,14 @@
color: var(--mt-primary);
}
-.modTools .collapseToggle svg {
+.modTools .collapseToggle img {
width: 14px;
height: 14px;
transition: transform var(--mt-transition);
}
/* Collapsed state */
-.modTools .modToolsSection.collapsed .collapseToggle svg {
+.modTools .modToolsSection.collapsed .collapseToggle img {
transform: rotate(-90deg);
}
diff --git a/static/js/modtools/components/shared/ModToolsSection.jsx b/static/js/modtools/components/shared/ModToolsSection.jsx
index 6ba5c6e166..4542ef6410 100644
--- a/static/js/modtools/components/shared/ModToolsSection.jsx
+++ b/static/js/modtools/components/shared/ModToolsSection.jsx
@@ -27,22 +27,6 @@ import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import HelpButton from './HelpButton';
-/**
- * Chevron icon component for collapse indicator
- */
-const ChevronIcon = () => (
-
-
-
-);
-
/**
* ModToolsSection component
*
@@ -100,7 +84,7 @@ const ModToolsSection = ({
>
-
+
{title &&
{title} }
From 6a0131bcead4ac25802b5b3df2bd073efcd9a8f1 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Wed, 14 Jan 2026 14:05:06 +0200
Subject: [PATCH 17/26] chore(modtools): Remove disabled tool components
Remove BulkIndexEditor, AutoLinkCommentaryTool, and NodeTitleEditor from
this PR. These tools have been moved to separate branches:
- feature/sc-36473/kehati-tools-bulk-edit-index-metadata (BulkIndexEditor)
- feature/sc-36476/kehati-tools-auto-link-commentaries (AutoLinkCommentaryTool)
- feature/sc-36477/kehati-tools-edit-node-titles (NodeTitleEditor)
Each branch has the code preserved for future development with appropriate
notes about the inherited code status.
Co-Authored-By: Claude Opus 4.5
---
docs/modtools/COMPONENT_LOGIC.md | 240 +------
docs/modtools/MODTOOLS_GUIDE.md | 9 -
static/js/ModeratorToolsPanel.jsx | 16 -
.../components/AutoLinkCommentaryTool.jsx | 409 -----------
.../modtools/components/BulkIndexEditor.jsx | 671 ------------------
.../modtools/components/NodeTitleEditor.jsx | 501 -------------
static/js/modtools/index.js | 9 -
7 files changed, 2 insertions(+), 1853 deletions(-)
delete mode 100644 static/js/modtools/components/AutoLinkCommentaryTool.jsx
delete mode 100644 static/js/modtools/components/BulkIndexEditor.jsx
delete mode 100644 static/js/modtools/components/NodeTitleEditor.jsx
diff --git a/docs/modtools/COMPONENT_LOGIC.md b/docs/modtools/COMPONENT_LOGIC.md
index f24859e652..1e5247abde 100644
--- a/docs/modtools/COMPONENT_LOGIC.md
+++ b/docs/modtools/COMPONENT_LOGIC.md
@@ -10,11 +10,8 @@ This document provides detailed logic flows, decision trees, and implementation
1. [Shared Components](#shared-components)
2. [BulkVersionEditor](#bulkversioneditor)
-3. [BulkIndexEditor](#bulkindexeditor)
-4. [AutoLinkCommentaryTool](#autolinkcommentarytool)
-5. [NodeTitleEditor](#nodetitleeditor)
-6. [State Management Patterns](#state-management-patterns)
-7. [Error Handling Patterns](#error-handling-patterns)
+3. [State Management Patterns](#state-management-patterns)
+4. [Error Handling Patterns](#error-handling-patterns)
---
@@ -246,222 +243,6 @@ Response: { status: "ok"|"partial"|"error", count, total, successes, failures }
---
-## BulkIndexEditor
-
-**Purpose**: Bulk edit Index (text catalog) metadata with auto-detection for commentaries.
-
-### Key Differences from BulkVersionEditor
-
-1. **Updates Index records** (text metadata) not Version records (translations)
-2. **Auto-detection** for commentary fields using "X on Y" title pattern
-3. **Author validation** against AuthorTopic database
-4. **Term creation** for collective titles
-5. **Sequential API calls** (one per index) instead of single bulk call
-
-### Commentary Auto-Detection Logic
-
-```javascript
-const detectCommentaryPattern = (title) => {
- const match = title.match(/^(.+?)\s+on\s+(.+)$/);
- if (match) {
- return {
- commentaryName: match[1].trim(), // e.g., "Rashi"
- baseText: match[2].trim() // e.g., "Genesis"
- };
- }
- return null;
-};
-```
-
-**Usage in auto-detection**:
-```javascript
-// If user enters 'auto' for a field:
-if (indexSpecificUpdates.dependence === 'auto') {
- indexSpecificUpdates.dependence = pattern ? 'Commentary' : undefined;
-}
-
-if (indexSpecificUpdates.base_text_titles === 'auto') {
- indexSpecificUpdates.base_text_titles = pattern ? [pattern.baseText] : undefined;
-}
-
-if (indexSpecificUpdates.collective_title === 'auto') {
- indexSpecificUpdates.collective_title = pattern ? pattern.commentaryName : undefined;
-}
-```
-
-### Term Creation Flow
-
-Terms are required for collective titles to display properly:
-
-```javascript
-const createTermIfNeeded = async (enTitle, heTitle) => {
- // 1. Check if term exists
- try {
- await $.get(`/api/terms/${encodeURIComponent(enTitle)}`);
- return true; // Already exists
- } catch (e) {
- if (e.status === 404) {
- // 2. Create new term
- await $.post(`/api/terms/${encodeURIComponent(enTitle)}`, {
- json: JSON.stringify({
- name: enTitle,
- titles: [
- { lang: "en", text: enTitle, primary: true },
- { lang: "he", text: heTitle, primary: true }
- ]
- })
- });
- }
- }
-};
-```
-
-### TOC Zoom Handling
-
-TOC zoom is applied to schema nodes, not the index directly:
-
-```javascript
-if ('toc_zoom' in indexSpecificUpdates) {
- const tocZoomValue = indexSpecificUpdates.toc_zoom;
- delete indexSpecificUpdates.toc_zoom; // Remove from direct updates
-
- // Apply to all JaggedArrayNode nodes in schema
- if (existingIndexData.schema?.nodes) {
- existingIndexData.schema.nodes.forEach(node => {
- if (node.nodeType === "JaggedArrayNode") {
- node.toc_zoom = tocZoomValue;
- }
- });
- } else if (existingIndexData.schema) {
- existingIndexData.schema.toc_zoom = tocZoomValue;
- }
-}
-```
-
----
-
-## AutoLinkCommentaryTool
-
-**Purpose**: Create automatic links between commentaries and their base texts.
-
-### Workflow
-
-```
-[SEARCH] --> Filter for " on " pattern --> [COMMENTARIES_FOUND]
- |
- v
- [SELECT_COMMENTARIES]
- |
- v
- [CHOOSE_MAPPING]
- |
- v
- [CREATE_LINKS]
- |
- For each commentary:
- 1. Fetch index data
- 2. Extract base text from title
- 3. Patch index with commentary metadata
- 4. Clear caches
- 5. Rebuild auto-links
-```
-
-### Mapping Algorithm Selection
-
-| Algorithm | Use Case | Structure |
-|-----------|----------|-----------|
-| `many_to_one_default_only` | Most commentaries | Rashi 1:1:1, 1:1:2, 1:1:3 → Genesis 1:1 |
-| `many_to_one` | With alt structures | Same + alternate verse numberings |
-| `one_to_one_default_only` | Translations | Chapter 1:1 = Chapter 1:1 |
-| `one_to_one` | Translations + alts | Same + alternate structures |
-
-### Idempotency
-
-The tool is idempotent - running it multiple times is safe:
-```javascript
-if (!raw.base_text_titles || !raw.base_text_mapping) {
- // Only patch if fields are missing
- const patched = { ...raw, dependence: "Commentary", ... };
- await $.post(url, { json: JSON.stringify(patched) });
-}
-// Always rebuild links (safe to re-run)
-await $.get(`/admin/rebuild/auto-links/${title}`);
-```
-
----
-
-## NodeTitleEditor
-
-**Purpose**: Edit titles of schema nodes within an Index.
-
-### Node Extraction Logic
-
-Recursively traverses the schema to build a flat list of editable nodes:
-
-```javascript
-const extractNodes = (schema, path = []) => {
- let nodes = [];
-
- if (schema.nodes) {
- schema.nodes.forEach((node, index) => {
- const nodePath = [...path, index];
- nodes.push({
- path: nodePath,
- pathStr: nodePath.join('.'), // "0.1.2" for nodes[0].nodes[1].nodes[2]
- node: node,
- sharedTitle: node.sharedTitle,
- title: node.title,
- heTitle: node.heTitle
- });
-
- // Recurse into children
- if (node.nodes) {
- nodes = nodes.concat(extractNodes(node, nodePath));
- }
- });
- }
-
- return nodes;
-};
-```
-
-### Validation Rules
-
-```javascript
-// English titles: ASCII only, no special characters
-const isValidEnglishTitle = (title) => {
- const isAscii = title.match(/^[\x00-\x7F]*$/);
- const noForbidden = !title.match(/[:.\\/-]/);
- return isAscii && noForbidden;
-};
-
-// Hebrew titles: No restrictions (any Unicode allowed)
-```
-
-### Shared Title Handling
-
-Some nodes use "shared titles" (Terms) that can be reused across texts:
-
-```javascript
-// When removing shared title:
-if (edits.removeSharedTitle) {
- delete targetNode.sharedTitle;
- // Node will now use direct title/heTitle fields
-}
-```
-
-### Dependency Warning
-
-Before editing, the tool checks what depends on this index:
-```javascript
-const checkDependencies = async (title) => {
- const response = await $.get(`/api/check-index-dependencies/${title}`);
- // Response includes: dependent_count, dependent_indices, version_count, link_count
-};
-```
-
----
-
## State Management Patterns
### Common State Categories
@@ -576,21 +357,4 @@ BulkVersionEditor.jsx
├── imports: VERSION_FIELD_METADATA (fieldMetadata.js)
├── imports: ModToolsSection, IndexSelector, StatusMessage (shared/)
└── API: /api/version-indices, /api/version-bulk-edit
-
-BulkIndexEditor.jsx
- ├── imports: INDEX_FIELD_METADATA (fieldMetadata.js)
- ├── imports: Sefaria (for getIndexDetails)
- ├── imports: ModToolsSection, IndexSelector, StatusMessage (shared/)
- └── API: /api/version-indices, /api/v2/raw/index, /admin/reset, /api/terms
-
-AutoLinkCommentaryTool.jsx
- ├── imports: BASE_TEXT_MAPPING_OPTIONS (fieldMetadata.js)
- ├── imports: Sefaria (for getIndexDetails)
- ├── imports: ModToolsSection, IndexSelector, StatusMessage (shared/)
- └── API: /api/version-indices, /api/v2/raw/index, /admin/reset, /admin/rebuild/auto-links
-
-NodeTitleEditor.jsx
- ├── imports: Sefaria (for getIndexDetails)
- ├── imports: ModToolsSection, StatusMessage (shared/)
- └── API: /api/v2/raw/index, /admin/reset, /api/check-index-dependencies
```
diff --git a/docs/modtools/MODTOOLS_GUIDE.md b/docs/modtools/MODTOOLS_GUIDE.md
index 4f1af0ad11..3bb53bbed5 100644
--- a/docs/modtools/MODTOOLS_GUIDE.md
+++ b/docs/modtools/MODTOOLS_GUIDE.md
@@ -41,9 +41,6 @@ static/js/
│ └── fieldMetadata.js # VERSION_FIELD_METADATA, INDEX_FIELD_METADATA
└── components/
├── BulkVersionEditor.jsx # Version metadata bulk editor
- ├── BulkIndexEditor.jsx # Index metadata (disabled)
- ├── AutoLinkCommentaryTool.jsx # Commentary linker (disabled)
- ├── NodeTitleEditor.jsx # Node title editor (disabled)
└── shared/
├── index.js
├── ModToolsSection.jsx # Collapsible section wrapper
@@ -94,12 +91,6 @@ Deletes links from CSV.
Edit metadata across multiple Version records.
- Endpoints: `GET /api/version-indices`, `POST /api/version-bulk-edit`
-### Disabled Tools (Backend APIs Remain Functional)
-
-- **BulkIndexEditor**: Bulk edit index metadata
-- **AutoLinkCommentaryTool**: Auto-link commentaries
-- **NodeTitleEditor**: Edit schema node titles
-
---
## API Endpoints
diff --git a/static/js/ModeratorToolsPanel.jsx b/static/js/ModeratorToolsPanel.jsx
index ea8f1ce50d..978b365637 100644
--- a/static/js/ModeratorToolsPanel.jsx
+++ b/static/js/ModeratorToolsPanel.jsx
@@ -10,10 +10,6 @@
* - Links management (upload/download/remove)
* - Bulk editing of Version metadata
*
- * NOTE: The following tools are temporarily disabled (open tickets to reintroduce):
- * - Bulk editing of Index metadata (BulkIndexEditor)
- * - Auto-linking commentaries to base texts (AutoLinkCommentaryTool)
- * - Editing node titles in Index schemas (NodeTitleEditor)
*
* Documentation:
* - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
@@ -34,18 +30,6 @@ import DownloadLinks from './modtools/components/DownloadLinks';
import RemoveLinksFromCsv from './modtools/components/RemoveLinksFromCsv';
import BulkVersionEditor from './modtools/components/BulkVersionEditor';
-// TODO: The following tools are temporarily disabled. There are open tickets to reintroduce them:
-// - BulkIndexEditor: Bulk edit index metadata
-// - AutoLinkCommentaryTool: Auto-link commentaries to base texts
-// - NodeTitleEditor: Edit node titles within an Index schema
-// When re-enabling NodeTitleEditor/BulkIndexEditor, the index_post function in reader/views.py
-// will need enhanced error handling (dependency checks, categorized error responses).
-// See PR #2984 review comments for context.
-// import BulkIndexEditor from './modtools/components/BulkIndexEditor';
-// import AutoLinkCommentaryTool from './modtools/components/AutoLinkCommentaryTool';
-// import NodeTitleEditor from './modtools/components/NodeTitleEditor';
-
-
/**
* ModeratorToolsPanel - Main container component
*
diff --git a/static/js/modtools/components/AutoLinkCommentaryTool.jsx b/static/js/modtools/components/AutoLinkCommentaryTool.jsx
deleted file mode 100644
index 9d5c9973bc..0000000000
--- a/static/js/modtools/components/AutoLinkCommentaryTool.jsx
+++ /dev/null
@@ -1,409 +0,0 @@
-/**
- * AutoLinkCommentaryTool - Automatically create links between commentaries and base texts
- *
- * NOTE: This component is currently DISABLED in ModeratorToolsPanel.
- * It is retained for future re-enablement but not rendered in the UI.
- *
- * This tool helps set up the commentary linking infrastructure for texts that follow
- * the "X on Y" naming pattern (e.g., "Rashi on Genesis").
- *
- * Workflow:
- * 1. User enters a versionTitle to find commentary indices
- * 2. Tool filters for indices with " on " in the title
- * 3. User selects which commentaries to process
- * 4. Tool patches each Index with:
- * - dependence: "Commentary"
- * - base_text_titles: [guessed base text]
- * - base_text_mapping: selected algorithm
- * 5. Tool triggers /admin/rebuild/auto-links/ for each
- *
- * Backend APIs:
- * - POST /api/v2/raw/index/{title}?update=1 - Update index record
- * - GET /admin/reset/{title} - Clear caches
- * - GET /admin/rebuild/auto-links/{title} - Rebuild commentary links
- *
- * Documentation:
- * - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
- * - See /docs/modtools/COMPONENT_LOGIC.md for detailed implementation logic
- * - Mapping algorithms are defined in ../constants/fieldMetadata.js
- */
-import React, { useState } from 'react';
-import Sefaria from '../../sefaria/sefaria';
-import { BASE_TEXT_MAPPING_OPTIONS } from '../constants/fieldMetadata';
-import ModToolsSection from './shared/ModToolsSection';
-import IndexSelector from './shared/IndexSelector';
-import StatusMessage from './shared/StatusMessage';
-
-/**
- * Detailed help documentation for this tool
- */
-const HELP_CONTENT = (
- <>
- What This Tool Does
-
- This tool automatically creates links between commentaries and their base texts .
- When a user views "Genesis 1:1", they see connections to "Rashi on Genesis 1:1:1" and other
- commentaries. This tool generates those connections automatically.
-
-
- The tool works by setting up commentary metadata on the Index record and then triggering
- Sefaria's auto-linking system to generate the actual link records.
-
-
- How It Works
-
- Search: Enter a version title to find commentary texts (texts with "X on Y" pattern).
- Select: Choose which commentaries to link.
- Choose mapping: Select how commentary sections map to base text sections.
- Create Links: The tool patches each Index with commentary metadata, then triggers link building.
-
-
- What Gets Changed
- For each selected commentary, the tool sets:
-
- dependence: "Commentary" - Marks the text as a commentary
- base_text_titles - The text(s) being commented on (extracted from title pattern)
- base_text_mapping - The algorithm for mapping commentary refs to base refs
-
- Then it calls /admin/rebuild/auto-links/ to generate the actual link records.
-
- Mapping Algorithms
-
-
- Algorithm Use Case Example
-
-
-
- many_to_one_default_only
- Most common. Multiple commentary segments per verse.
- Rashi on Genesis 1:1:1, 1:1:2, 1:1:3 all link to Genesis 1:1
-
-
- many_to_one
- Like above but includes alt structures.
- Same as above, but also links to alt verse numberings
-
-
- one_to_one_default_only
- One commentary segment per base segment.
- Translation where Chapter 1:1 = Chapter 1:1
-
-
- one_to_one
- Like above but includes alt structures.
- Same as above with alt structures
-
-
-
-
-
- Which mapping should I use?
- For most Tanakh commentaries (Rashi, Ibn Ezra, etc.) and Mishnah commentaries (Kehati, Bartenura),
- use many_to_one_default_only. This is the default and works for commentaries where
- each verse/mishnah has multiple comment segments.
-
-
- What "X on Y" Pattern Means
-
- The tool only works with texts that follow the "X on Y" naming pattern:
-
-
- "Rashi on Genesis" - Commentary name is "Rashi", base text is "Genesis"
- "Kehati on Mishnah Berakhot" - Commentary is "Kehati", base is "Mishnah Berakhot"
- "Ibn Ezra on Psalms" - Commentary is "Ibn Ezra", base is "Psalms"
-
-
- The tool extracts the base text name from after " on " in the title and uses that
- to set base_text_titles.
-
-
-
-
Important Notes:
-
- This tool is idempotent - running it multiple times is safe.
- Only texts with " on " in their title are shown (non-commentaries are filtered out).
- The base text (e.g., "Genesis") must already exist in Sefaria for links to work.
- Link building may take a few seconds per text.
- Links update automatically when text content changes.
-
-
-
- Common Use Cases
-
- Setting up links for a new commentary series just uploaded
- Re-linking commentaries after the base text structure changed
- Fixing commentaries that were uploaded without proper linking metadata
-
-
- Troubleshooting
-
- "Title pattern didn't reveal base text" - The index title doesn't match "X on Y" pattern. Rename the index first.
- Links not appearing - Make sure the base text exists and the ref structure matches.
- Wrong links - Try a different mapping algorithm. "many_to_one" vs "one_to_one" depends on commentary structure.
-
- >
-);
-
-const AutoLinkCommentaryTool = () => {
- // Search state
- const [vtitle, setVtitle] = useState("");
- const [lang, setLang] = useState("");
- const [searched, setSearched] = useState(false);
-
- // Results state
- // indices: Array of {title: string, categories?: string[]} objects
- const [indices, setIndices] = useState([]);
- const [pick, setPick] = useState(new Set());
-
- // Options state
- const [mapping, setMapping] = useState("many_to_one_default_only");
-
- // UI state
- const [msg, setMsg] = useState("");
- const [loading, setLoading] = useState(false);
- const [linking, setLinking] = useState(false);
-
- /**
- * Clear search and reset state
- */
- const clearSearch = () => {
- setIndices([]);
- setPick(new Set());
- setMsg("");
- setSearched(false);
- };
-
- /**
- * Load indices that have " on " in their title (commentary pattern)
- */
- const load = async () => {
- if (!vtitle.trim()) {
- setMsg("Please enter a version title");
- return;
- }
-
- setLoading(true);
- setSearched(true);
- setMsg("Loading indices...");
-
- const urlParams = { versionTitle: vtitle };
- if (lang) {
- urlParams.language = lang;
- }
-
- try {
- const data = await Sefaria.apiRequestWithBody('/api/version-indices', urlParams, null, 'GET');
- const resultMetadata = data.metadata || {};
- // Filter for commentary pattern and combine with metadata
- const commentaryIndices = (data.indices || [])
- .filter(title => title.includes(" on "))
- .map(title => ({
- title,
- categories: resultMetadata[title]?.categories
- }));
- setIndices(commentaryIndices);
- setPick(new Set(commentaryIndices.map(item => item.title))); // Set of title strings
- if (commentaryIndices.length > 0) {
- setMsg(`Found ${commentaryIndices.length} commentaries with version "${vtitle}"`);
- } else {
- setMsg("");
- }
- } catch (error) {
- setMsg(`Error: ${error.message || "Failed to load indices"}`);
- setIndices([]);
- setPick(new Set());
- } finally {
- setLoading(false);
- }
- };
-
- /**
- * Create links for selected commentaries
- */
- const createLinks = async () => {
- if (!pick.size) return;
-
- setLinking(true);
- setMsg("Creating links...");
-
- let successCount = 0;
- const errors = [];
-
- for (const indexTitle of pick) {
- try {
- // 1. Fetch current Index
- const raw = await Sefaria.getIndexDetails(indexTitle);
- if (!raw) throw new Error("Couldn't fetch index JSON");
-
- // 2. Guess base work from "... on " pattern
- const guess = (indexTitle.match(/ on (.+)$/) || [])[1];
- if (!guess) throw new Error("Title pattern didn't reveal base text");
-
- // 3. Add missing commentary metadata (idempotent)
- if (!raw.base_text_titles || !raw.base_text_mapping) {
- const patched = {
- ...raw,
- dependence: "Commentary",
- base_text_titles: raw.base_text_titles || [guess],
- base_text_mapping: raw.base_text_mapping || mapping
- };
- delete patched._id;
-
- const urlParams = { update: '1' };
- const indexPath = encodeURIComponent(indexTitle.replace(/ /g, "_"));
- const payload = { json: JSON.stringify(patched) };
-
- // Update index via raw API
- await Sefaria.apiRequestWithBody(`/api/v2/raw/index/${indexPath}`, urlParams, payload);
-
- // Clear caches (non-JSON endpoint)
- const resetResponse = await fetch(`/admin/reset/${encodeURIComponent(indexTitle)}`, {
- method: 'GET',
- credentials: 'same-origin'
- });
- if (!resetResponse.ok) {
- throw new Error("Failed to reset cache");
- }
- }
-
- // 4. Rebuild links (non-JSON endpoint)
- setMsg(`Building links for ${indexTitle}...`);
- const rebuildPath = encodeURIComponent(indexTitle.replace(/ /g, "_"));
- const rebuildResponse = await fetch(`/admin/rebuild/auto-links/${rebuildPath}`, {
- method: 'GET',
- credentials: 'same-origin'
- });
- if (!rebuildResponse.ok) {
- throw new Error("Failed to rebuild links");
- }
-
- successCount++;
- } catch (e) {
- const m = e.message || "Unknown error";
- errors.push(`${indexTitle}: ${m}`);
- }
- }
-
- setMsg(
- errors.length
- ? `Finished. Linked ${successCount}/${pick.size}. Errors: ${errors.join("; ")}`
- : `Links built for all ${successCount} indices`
- );
- setLinking(false);
- };
-
- return (
-
- {/* Info box */}
-
- How it works: This tool automatically creates links between commentaries
- and their base texts. For example, "Rashi on Genesis 1:1:1" will be linked to "Genesis 1:1".
- Links update dynamically when text changes.
-
-
- {/* Search bar */}
-
- setVtitle(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && load()}
- />
-
- {loading ? <> Searching...> : "Find Commentaries"}
-
-
-
- {/* Language filter - inline */}
-
- Filter by language:
- setLang(e.target.value)}
- >
- All languages
- Hebrew only
- English only
-
-
-
- {/* Clear button - centered */}
- {searched && (
-
-
- Clear Search
-
-
- )}
-
- {/* No results message */}
- {searched && !loading && indices.length === 0 && (
-
- No commentaries found with version "{vtitle}"
- This tool only finds texts with " on " in their title (e.g., "Rashi on Genesis").
- Verify the version title is correct and contains commentary texts.
-
- )}
-
- {/* Index selector */}
- {indices.length > 0 && (
- <>
-
-
- {/* Mapping selector */}
-
- base_text_mapping:
- setMapping(e.target.value)}
- >
- {BASE_TEXT_MAPPING_OPTIONS.map(opt => (
- {opt.label}
- ))}
-
-
-
- {/* Action button */}
-
-
- {linking ? (
- <> Creating Links...>
- ) : (
- `Create Links for ${pick.size} Commentaries`
- )}
-
-
- >
- )}
-
-
-
- );
-};
-
-export default AutoLinkCommentaryTool;
diff --git a/static/js/modtools/components/BulkIndexEditor.jsx b/static/js/modtools/components/BulkIndexEditor.jsx
deleted file mode 100644
index 14b445fbcc..0000000000
--- a/static/js/modtools/components/BulkIndexEditor.jsx
+++ /dev/null
@@ -1,671 +0,0 @@
-/**
- * BulkIndexEditor - Bulk edit Index metadata across multiple indices
- *
- * NOTE: This component is currently DISABLED in ModeratorToolsPanel.
- * It is retained for future re-enablement but not rendered in the UI.
- *
- * Similar workflow to BulkVersionEditor, but operates on Index records
- * (the text metadata) rather than Version records (text content/translations).
- *
- * Workflow:
- * 1. User enters a versionTitle to find indices that have matching versions
- * 2. User selects which indices to update
- * 3. User fills in index metadata fields
- * 4. On save, updates each Index via the raw API
- *
- * Special features:
- * - Auto-detection for commentary texts ("X on Y" pattern)
- * - Automatic term creation for collective titles
- * - Author validation against AuthorTopic
- * - TOC zoom level setting on schema nodes
- *
- * Backend API: POST /api/v2/raw/index/{title}?update=1
- *
- * Documentation:
- * - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
- * - See /docs/modtools/COMPONENT_LOGIC.md for detailed implementation logic
- * - Index fields are defined in ../constants/fieldMetadata.js
- */
-import { useState, useEffect } from 'react';
-import Sefaria from '../../sefaria/sefaria';
-import { INDEX_FIELD_METADATA } from '../constants/fieldMetadata';
-import ModToolsSection from './shared/ModToolsSection';
-import IndexSelector from './shared/IndexSelector';
-import StatusMessage from './shared/StatusMessage';
-
-/**
- * Detailed help documentation for this tool
- */
-const HELP_CONTENT = (
- <>
- What This Tool Does
-
- This tool edits Index metadata (text catalog records) across multiple texts.
- An "Index" in Sefaria is the master record for a text, containing its title, category,
- authorship, composition date, and structural information.
-
-
- Unlike the Version Editor which edits translations/editions, this tool edits the
- underlying text record itself. Use this when you need to update catalog information
- like descriptions, categories, authors, or commentary relationships.
-
-
- How It Works
-
- Search: Enter a version title to find all indices that have versions with that title.
- Select: Choose which indices to update.
- Edit: Fill in the metadata fields you want to change.
- Save: Each index is updated individually via the API, with cache clearing.
-
-
- Available Fields
-
-
- Field Description
-
-
- enDescEnglish description of the text (shown in reader)
- heDescHebrew description of the text
- enShortDescBrief English description for search results
- heShortDescBrief Hebrew description
- categoriesCategory path in the table of contents (e.g., "Mishnah, Seder Zeraim")
- authorsAuthor slugs (must exist in AuthorTopic). Comma-separated.
- compDateComposition date. Single year or range like [1200, 1250]
- compPlacePlace of composition (English)
- heCompPlacePlace of composition (Hebrew)
- pubDatePublication date
- pubPlacePlace of publication (English)
- hePubPlacePlace of publication (Hebrew)
- dependence"Commentary" or "Targum" - marks text as dependent on another
- base_text_titlesFor commentaries: exact titles of base texts. Comma-separated.
- collective_titleFor commentaries: the commentary name (e.g., "Rashi")
- he_collective_titleHebrew collective title (creates a Term if both en/he provided)
- toc_zoomTable of contents zoom level (0-10, 0=fully expanded)
-
-
-
- Auto-Detection Feature
-
- For texts with "X on Y" naming pattern (e.g., "Rashi on Genesis"), you can use
- 'auto' as a value for certain fields:
-
-
- dependence: 'auto' - Sets to "Commentary" if pattern detected
- base_text_titles: 'auto' - Extracts the base text name (e.g., "Genesis")
- collective_title: 'auto' - Extracts the commentary name (e.g., "Rashi")
- authors: 'auto' - Looks up the commentary name as an AuthorTopic
-
-
- Term Creation
-
- If you provide both collective_title (English) and he_collective_title
- (Hebrew), the tool will automatically create a Term for that collective title if it
- doesn't already exist. This is required for the collective title to display properly.
-
-
-
-
Important Notes:
-
- Authors must exist in the AuthorTopic database. Invalid author names will fail validation.
- Base text titles must be exact index titles (e.g., "Mishnah Berakhot", not "Mishnah").
- Categories must match existing category paths in the Sefaria table of contents.
- Changes trigger a cache reset for each index, which may take a moment.
- Changes are applied immediately to production . There is no undo.
-
-
-
- Common Use Cases
-
- Adding descriptions to a set of related texts
- Setting up commentary metadata for a new commentary series
- Moving texts to a different category
- Adding authorship information to texts by the same author
- Configuring TOC display depth for complex texts
-
- >
-);
-
-/**
- * Detect commentary pattern from title (e.g., "Rashi on Genesis")
- * Returns { commentaryName, baseText } or null
- */
-const detectCommentaryPattern = (title) => {
- const match = title.match(/^(.+?)\s+on\s+(.+)$/);
- if (match) {
- return {
- commentaryName: match[1].trim(),
- baseText: match[2].trim()
- };
- }
- return null;
-};
-
-/**
- * Create a term if it doesn't exist
- */
-const createTermIfNeeded = async (enTitle, heTitle) => {
- if (!enTitle || !heTitle) {
- throw new Error("Both English and Hebrew titles are required to create a term");
- }
-
- try {
- // Check if term already exists
- await Sefaria.apiRequestWithBody(`/api/terms/${encodeURIComponent(enTitle)}`, null, null, 'GET');
- return true; // Term exists
- } catch (e) {
- if (e.message.includes('404') || e.message.includes('not found')) {
- // Create term
- const payload = {
- json: JSON.stringify({
- name: enTitle,
- titles: [
- { lang: "en", text: enTitle, primary: true },
- { lang: "he", text: heTitle, primary: true }
- ]
- })
- };
- await Sefaria.apiRequestWithBody(`/api/terms/${encodeURIComponent(enTitle)}`, null, payload);
- return true;
- }
- throw e;
- }
-};
-
-const BulkIndexEditor = () => {
- // Search state
- const [vtitle, setVtitle] = useState("");
- const [lang, setLang] = useState("");
- const [searched, setSearched] = useState(false);
-
- // Results state
- // indices: Array of {title: string, categories?: string[]} objects
- const [indices, setIndices] = useState([]);
- const [pick, setPick] = useState(new Set());
- const [categories, setCategories] = useState([]);
-
- // Edit state
- const [updates, setUpdates] = useState({});
-
- // UI state
- const [msg, setMsg] = useState("");
- const [loading, setLoading] = useState(false);
- const [saving, setSaving] = useState(false);
-
- // Load categories on mount
- useEffect(() => {
- const loadCategories = async () => {
- try {
- const data = await Sefaria.apiRequestWithBody('/api/index', null, null, 'GET');
- const cats = [];
- const extractCategories = (node, path = []) => {
- if (node.category) {
- const fullPath = [...path, node.category].join(", ");
- cats.push(fullPath);
- }
- if (node.contents) {
- node.contents.forEach(item => {
- extractCategories(item, node.category ? [...path, node.category] : path);
- });
- }
- };
- data.forEach(cat => extractCategories(cat));
- setCategories(cats.sort());
- } catch (error) {
- console.error('Failed to load categories:', error);
- }
- };
- loadCategories();
- }, []);
-
- /**
- * Clear search and reset state
- */
- const clearSearch = () => {
- setIndices([]);
- setPick(new Set());
- setUpdates({});
- setMsg("");
- setSearched(false);
- };
-
- /**
- * Load indices matching the version title
- */
- const load = async () => {
- if (!vtitle.trim()) {
- setMsg("Please enter a version title");
- return;
- }
-
- setLoading(true);
- setSearched(true);
- setMsg("Loading indices...");
-
- const urlParams = { versionTitle: vtitle };
- if (lang) {
- urlParams.language = lang;
- }
-
- try {
- const data = await Sefaria.apiRequestWithBody('/api/version-indices', urlParams, null, 'GET');
- const resultIndices = data.indices || [];
- const resultMetadata = data.metadata || {};
- // Combine indices and metadata into single array of objects
- const combinedIndices = resultIndices.map(title => ({
- title,
- categories: resultMetadata[title]?.categories
- }));
- setIndices(combinedIndices);
- setPick(new Set(resultIndices)); // Set of title strings
- if (resultIndices.length > 0) {
- setMsg(`Found ${resultIndices.length} indices with version "${vtitle}"`);
- } else {
- setMsg("");
- }
- } catch (error) {
- setMsg(`Error: ${error.message || "Failed to load indices"}`);
- setIndices([]);
- setPick(new Set());
- } finally {
- setLoading(false);
- }
- };
-
- /**
- * Handle field value changes
- */
- const handleFieldChange = (fieldName, value) => {
- setUpdates(prev => ({ ...prev, [fieldName]: value }));
- };
-
- /**
- * Save changes to selected indices
- */
- const save = async () => {
- if (!pick.size || !Object.keys(updates).length) return;
-
- setSaving(true);
- setMsg("Saving changes...");
-
- // Process updates to ensure correct data types
- const processedUpdates = {};
-
- for (const [field, value] of Object.entries(updates)) {
- if (!value && field !== "toc_zoom") continue;
-
- const fieldMeta = INDEX_FIELD_METADATA[field];
- if (!fieldMeta) {
- processedUpdates[field] = value;
- continue;
- }
-
- switch (fieldMeta.type) {
- case 'array':
- if (value === 'auto') {
- processedUpdates[field] = 'auto';
- } else {
- processedUpdates[field] = value.split(',').map(v => v.trim()).filter(v => v);
- }
- break;
- case 'daterange':
- if (value.startsWith('[') && value.endsWith(']')) {
- try {
- processedUpdates[field] = JSON.parse(value);
- } catch (e) {
- setMsg(`Invalid date format for ${field}`);
- setSaving(false);
- return;
- }
- } else {
- const year = parseInt(value);
- if (!isNaN(year)) {
- processedUpdates[field] = year;
- } else {
- setMsg(`Invalid date format for ${field}`);
- setSaving(false);
- return;
- }
- }
- break;
- case 'number':
- const numValue = parseInt(value);
- if (isNaN(numValue)) {
- setMsg(`Invalid number format for ${field}`);
- setSaving(false);
- return;
- }
- processedUpdates[field] = numValue;
- break;
- default:
- processedUpdates[field] = value;
- }
- }
-
- // Validate authors if present
- if (processedUpdates.authors && processedUpdates.authors !== 'auto') {
- try {
- const authorSlugs = [];
- for (const authorName of processedUpdates.authors) {
- const response = await Sefaria.apiRequestWithBody(`/api/name/${authorName}`, null, null, 'GET');
- const matches = response.completion_objects?.filter(t => t.type === 'AuthorTopic') || [];
- const exactMatch = matches.find(t => t.title.toLowerCase() === authorName.toLowerCase());
-
- if (!exactMatch) {
- const closestMatches = matches.map(t => t.title).slice(0, 3);
- const msg = matches.length > 0
- ? `Invalid author "${authorName}". Did you mean: ${closestMatches.join(', ')}?`
- : `Invalid author "${authorName}". Make sure it exists in the Authors topic.`;
- setMsg(`Error: ${msg}`);
- setSaving(false);
- return;
- }
- authorSlugs.push(exactMatch.key);
- }
- processedUpdates.authors = authorSlugs;
- } catch (e) {
- setMsg(`Error validating authors`);
- setSaving(false);
- return;
- }
- }
-
- let successCount = 0;
- const errors = [];
-
- for (const indexTitle of pick) {
- try {
- setMsg(`Updating ${indexTitle}...`);
-
- const existingIndexData = await Sefaria.getIndexDetails(indexTitle);
- if (!existingIndexData) {
- errors.push(`${indexTitle}: Could not fetch existing index data.`);
- continue;
- }
-
- let indexSpecificUpdates = { ...processedUpdates };
- const pattern = detectCommentaryPattern(indexTitle);
-
- // Handle auto-detection for various fields
- if (indexSpecificUpdates.dependence === 'auto') {
- indexSpecificUpdates.dependence = pattern ? 'Commentary' : undefined;
- if (!pattern) delete indexSpecificUpdates.dependence;
- }
-
- if (indexSpecificUpdates.base_text_titles === 'auto') {
- if (pattern?.baseText) {
- indexSpecificUpdates.base_text_titles = [pattern.baseText];
- } else {
- delete indexSpecificUpdates.base_text_titles;
- }
- }
-
- if (indexSpecificUpdates.collective_title === 'auto') {
- if (pattern?.commentaryName) {
- indexSpecificUpdates.collective_title = pattern.commentaryName;
- } else {
- delete indexSpecificUpdates.collective_title;
- }
- }
-
- // Handle term creation for collective_title
- if (indexSpecificUpdates.collective_title && indexSpecificUpdates.he_collective_title) {
- try {
- await createTermIfNeeded(indexSpecificUpdates.collective_title, indexSpecificUpdates.he_collective_title);
- delete indexSpecificUpdates.he_collective_title;
- } catch (e) {
- errors.push(`${indexTitle}: Failed to create term: ${e.message}`);
- continue;
- }
- }
-
- // Handle authors auto-detection
- if (indexSpecificUpdates.authors === 'auto' && pattern?.commentaryName) {
- try {
- const response = await Sefaria.apiRequestWithBody(`/api/name/${pattern.commentaryName}`, null, null, 'GET');
- const matches = response.completion_objects?.filter(t => t.type === 'AuthorTopic') || [];
- const exactMatch = matches.find(t => t.title.toLowerCase() === pattern.commentaryName.toLowerCase());
- if (exactMatch) {
- indexSpecificUpdates.authors = [exactMatch.key];
- } else {
- delete indexSpecificUpdates.authors;
- }
- } catch (e) {
- delete indexSpecificUpdates.authors;
- }
- }
-
- // Handle TOC zoom
- let tocZoomValue = null;
- if ('toc_zoom' in indexSpecificUpdates) {
- tocZoomValue = indexSpecificUpdates.toc_zoom;
- delete indexSpecificUpdates.toc_zoom;
-
- if (existingIndexData.schema?.nodes) {
- existingIndexData.schema.nodes.forEach(node => {
- if (node.nodeType === "JaggedArrayNode") {
- node.toc_zoom = tocZoomValue;
- }
- });
- } else if (existingIndexData.schema) {
- existingIndexData.schema.toc_zoom = tocZoomValue;
- }
- }
-
- const postData = {
- title: indexTitle,
- heTitle: existingIndexData.heTitle,
- categories: existingIndexData.categories,
- schema: existingIndexData.schema,
- ...indexSpecificUpdates
- };
-
- const urlParams = { update: '1' };
- const indexPath = encodeURIComponent(indexTitle.replace(/ /g, "_"));
- const payload = { json: JSON.stringify(postData) };
-
- // Update index via raw API
- await Sefaria.apiRequestWithBody(`/api/v2/raw/index/${indexPath}`, urlParams, payload);
-
- // Clear caches (non-JSON endpoint)
- const resetResponse = await fetch(`/admin/reset/${encodeURIComponent(indexTitle)}`, {
- method: 'GET',
- credentials: 'same-origin'
- });
- if (!resetResponse.ok) {
- throw new Error("Failed to reset cache");
- }
-
- successCount++;
- } catch (e) {
- const errorMsg = e.message || 'Unknown error';
- errors.push(`${indexTitle}: ${errorMsg}`);
- }
- }
-
- if (errors.length) {
- setMsg(`Updated ${successCount} of ${pick.size} indices. Errors: ${errors.join('; ')}`);
- } else {
- setMsg(`Successfully updated ${successCount} indices`);
- setUpdates({});
- }
- setSaving(false);
- };
-
- /**
- * Render a field input based on its metadata
- */
- const renderField = (fieldName) => {
- const fieldMeta = INDEX_FIELD_METADATA[fieldName];
- const currentValue = updates[fieldName] || "";
-
- const commonProps = {
- className: "dlVersionSelect fieldInput",
- placeholder: fieldMeta.placeholder,
- value: currentValue,
- onChange: e => handleFieldChange(fieldName, e.target.value),
- style: { width: "100%", direction: fieldMeta.dir || "ltr" }
- };
-
- return (
-
-
{fieldMeta.label}:
-
- {fieldMeta.help && (
-
- {fieldMeta.help}
- {fieldMeta.auto && (
- (Supports 'auto' for commentary texts)
- )}
-
- )}
-
- {fieldName === "categories" ? (
-
- Select category...
- {categories.map(cat => (
- {cat}
- ))}
-
- ) : fieldMeta.type === "select" && fieldMeta.options ? (
-
- {fieldMeta.options.map(option => (
- {option.label}
- ))}
-
- ) : fieldMeta.type === "textarea" ? (
-
- ) : fieldMeta.type === "number" ? (
-
- ) : (
-
- )}
-
- );
- };
-
- // Check if there are actual changes - toc_zoom of 0 is valid, so check for undefined instead
- const hasChanges = Object.keys(updates).filter(k => updates[k] || (k === 'toc_zoom' && updates[k] !== undefined && updates[k] !== '')).length > 0;
-
- return (
-
- {/* Warning box */}
-
-
Important Notes:
-
- Authors: Must exist in the Authors topic. Use exact names or slugs.
- Collective Title: If Hebrew equivalent is provided, terms will be created automatically.
- Base Text Titles: Must be exact index titles (e.g., "Mishnah Berakhot").
- Auto-detection: Works for commentary texts with "X on Y" format.
- TOC Zoom: Integer 0-10 (0=fully expanded).
-
-
-
- {/* Search bar */}
-
- setVtitle(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && load()}
- />
-
- {loading ? <> Searching...> : "Find Indices"}
-
-
-
- {/* Language filter - inline */}
-
- Filter by language:
- setLang(e.target.value)}
- >
- All languages
- Hebrew only
- English only
-
-
-
- {/* Clear button - centered */}
- {searched && (
-
-
- Clear Search
-
-
- )}
-
- {/* No results message */}
- {searched && !loading && indices.length === 0 && (
-
- No indices found with version "{vtitle}"
- Please verify the exact version title. Version titles are case-sensitive
- and must match exactly (e.g., "Torat Emet 357" not "torat emet").
-
- )}
-
- {/* Index selector */}
- {indices.length > 0 && (
-
- )}
-
- {/* Field inputs */}
- {pick.size > 0 && (
- <>
-
- Edit fields for {pick.size} selected {pick.size === 1 ? 'index' : 'indices'}:
-
-
-
- {Object.keys(INDEX_FIELD_METADATA).map(f => renderField(f))}
-
-
- {hasChanges && (
-
-
Changes to apply:
-
- {Object.entries(updates).filter(([k, v]) => v || k === 'toc_zoom').map(([k, v]) => (
- {INDEX_FIELD_METADATA[k]?.label || k}: "{v}"
- ))}
-
-
- )}
-
-
-
- {saving ? <> Saving...> : `Save Changes to ${pick.size} Indices`}
-
-
- >
- )}
-
-
-
- );
-};
-
-export default BulkIndexEditor;
diff --git a/static/js/modtools/components/NodeTitleEditor.jsx b/static/js/modtools/components/NodeTitleEditor.jsx
deleted file mode 100644
index 9271b584ce..0000000000
--- a/static/js/modtools/components/NodeTitleEditor.jsx
+++ /dev/null
@@ -1,501 +0,0 @@
-/**
- * NodeTitleEditor - Edit node titles within an Index schema
- *
- * NOTE: This component is currently DISABLED in ModeratorToolsPanel.
- * It is retained for future re-enablement but not rendered in the UI.
- *
- * Allows editing English and Hebrew titles for individual nodes in a text's
- * schema structure. Useful for fixing title errors or adding missing translations.
- *
- * Workflow:
- * 1. User enters an index title to load
- * 2. Tool displays all schema nodes with their current titles
- * 3. User edits titles as needed
- * 4. On save, tool updates the entire Index with modified schema
- *
- * Validation:
- * - English titles must be ASCII only
- * - No periods, hyphens, colons, or slashes allowed in titles
- * - Hebrew titles can be changed freely
- *
- * Backend API: POST /api/v2/raw/index/{title}
- *
- * Documentation:
- * - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
- * - See /docs/modtools/COMPONENT_LOGIC.md for detailed implementation logic
- */
-import React, { useState } from 'react';
-import $ from '../../sefaria/sefariaJquery';
-import Sefaria from '../../sefaria/sefaria';
-import ModToolsSection from './shared/ModToolsSection';
-import StatusMessage from './shared/StatusMessage';
-
-/**
- * Detailed help documentation for this tool
- */
-const HELP_CONTENT = (
- <>
- What This Tool Does
-
- This tool edits node titles within a text's schema structure.
- Every text in Sefaria has a schema that defines its structure (chapters, sections, etc.).
- Each structural unit is a "node" with English and Hebrew titles.
-
-
- Use this tool when you need to fix typos in section names, add missing Hebrew titles,
- or update how a text's internal structure is displayed.
-
-
- How It Works
-
- Load: Enter the exact index title (e.g., "Mishneh Torah, Laws of Kings").
- Review: All nodes in the schema are displayed with their current titles.
- Edit: Modify English or Hebrew titles as needed.
- Save: The entire Index is saved with the updated schema.
-
-
- What Are Nodes?
-
- Nodes are the structural building blocks of a text's schema. For example:
-
-
- Mishneh Torah has nodes for each "Laws of X" section
- Shulchan Arukh has nodes for Orach Chaim, Yoreh De'ah, etc.
- Complex texts may have nested nodes (sections within sections)
-
-
- The "Path" shown for each node (e.g., nodes[0][1][2]) indicates its
- position in the nested structure.
-
-
- Shared Titles
-
- Some nodes use a "shared title" (Term) instead of direct title strings. This allows
- the same title to be reused across different texts. If a node has a shared title,
- you'll see it displayed with an option to remove it.
-
-
- When you remove a shared title, the node will use its direct title
- and heTitle fields instead. This is useful when a text uses a generic
- term but needs a more specific title.
-
-
- Validation Rules
-
-
- Field Rules
-
-
-
- English titles
-
- Must be ASCII characters only. Cannot contain: periods (.), hyphens (-),
- colons (:), forward slashes (/), or backslashes (\).
-
-
-
- Hebrew titles
- No restrictions. Can contain any Unicode characters.
-
-
-
-
- Dependency Checking
-
- Before you edit, the tool checks what depends on this index:
-
-
- Dependent texts: Commentaries or other texts that reference this one
- Versions: Translations and editions of this text
- Links: Connections to other texts in the library
-
-
- A warning is shown if dependencies exist. Changing node titles on texts with many
- dependencies should be done carefully, as it may affect references.
-
-
-
-
Important Notes:
-
- English title restrictions are enforced because titles become part of reference URLs.
- Changing titles does not automatically update existing references or links.
- The tool saves the entire Index , not just the changed nodes.
- A cache reset is triggered after saving, which may take a moment.
- Changes are applied immediately to production . There is no undo.
-
-
-
- Common Use Cases
-
- Fixing typos in section or chapter names
- Adding missing Hebrew titles to nodes
- Standardizing title formats across similar texts
- Removing shared titles when a text needs custom naming
- Updating outdated or incorrect transliterations
-
-
- Troubleshooting
-
- "Invalid: ASCII only" - English title contains non-ASCII characters. Remove accents or special characters.
- "contains forbidden characters" - Title has periods, hyphens, colons, or slashes. Use spaces or other characters instead.
- Changes not visible - Clear your browser cache or wait a few minutes for the cache reset to propagate.
-
- >
-);
-
-const NodeTitleEditor = () => {
- // Index state
- const [indexTitle, setIndexTitle] = useState("");
- const [indexData, setIndexData] = useState(null);
-
- // Edit state
- const [editingNodes, setEditingNodes] = useState({});
-
- // Dependency state
- const [dependencies, setDependencies] = useState(null);
- const [checkingDeps, setCheckingDeps] = useState(false);
-
- // UI state
- const [msg, setMsg] = useState("");
- const [loading, setLoading] = useState(false);
- const [saving, setSaving] = useState(false);
-
- /**
- * Check what depends on this index
- */
- const checkDependencies = async (title) => {
- setCheckingDeps(true);
- try {
- const response = await $.get(`/api/check-index-dependencies/${encodeURIComponent(title)}`);
- setDependencies(response);
- } catch (e) {
- console.error('Failed to check dependencies:', e);
- setDependencies(null);
- }
- setCheckingDeps(false);
- };
-
- /**
- * Load the index and extract nodes
- */
- const load = async () => {
- if (!indexTitle.trim()) {
- setMsg("❌ Please enter an index title");
- return;
- }
-
- setLoading(true);
- setMsg("Loading index...");
-
- try {
- const data = await Sefaria.getIndexDetails(indexTitle);
- setIndexData(data);
- setEditingNodes({});
- setMsg(`✅ Loaded ${indexTitle}`);
- await checkDependencies(indexTitle);
- } catch (e) {
- setMsg(`❌ Error: Could not load index "${indexTitle}"`);
- setIndexData(null);
- setDependencies(null);
- }
- setLoading(false);
- };
-
- /**
- * Extract nodes from schema recursively
- */
- const extractNodes = (schema, path = []) => {
- let nodes = [];
-
- if (schema.nodes) {
- schema.nodes.forEach((node, index) => {
- const nodePath = [...path, index];
- nodes.push({
- path: nodePath,
- pathStr: nodePath.join('.'),
- node: node,
- titles: node.titles || [],
- sharedTitle: node.sharedTitle,
- title: node.title,
- heTitle: node.heTitle
- });
-
- // Recursively get child nodes
- if (node.nodes) {
- nodes = nodes.concat(extractNodes(node, nodePath));
- }
- });
- }
-
- return nodes;
- };
-
- /**
- * Handle node edit
- */
- const handleNodeEdit = (pathStr, field, value) => {
- setEditingNodes(prev => ({
- ...prev,
- [pathStr]: {
- ...prev[pathStr],
- [field]: value,
- // Add validation for English titles
- [`${field}_valid`]: field === 'title'
- ? (value.match(/^[\x00-\x7F]*$/) && !value.match(/[:.\\/-]/))
- : true
- }
- }));
- };
-
- /**
- * Save changes
- */
- const save = async () => {
- if (Object.keys(editingNodes).length === 0) {
- setMsg("❌ No changes to save");
- return;
- }
-
- setSaving(true);
- setMsg("Saving changes...");
-
- try {
- // Validate changes
- const validationErrors = [];
- Object.entries(editingNodes).forEach(([, edits]) => {
- if (edits.title !== undefined) {
- if (!edits.title.match(/^[\x00-\x7F]*$/)) {
- validationErrors.push(`English title "${edits.title}" contains non-ASCII characters`);
- }
- if (edits.title.match(/[:.\\/-]/)) {
- validationErrors.push(`English title "${edits.title}" contains forbidden characters`);
- }
- }
- });
-
- if (validationErrors.length > 0) {
- setMsg(`❌ Validation errors: ${validationErrors.join('; ')}`);
- setSaving(false);
- return;
- }
-
- // Create deep copy
- const updatedIndex = JSON.parse(JSON.stringify(indexData));
-
- // Apply edits
- Object.entries(editingNodes).forEach(([pathStr, edits]) => {
- const path = pathStr.split('.').map(Number);
- let node = updatedIndex.schema || updatedIndex;
-
- for (let i = 0; i < path.length; i++) {
- if (i === path.length - 1) {
- const targetNode = node.nodes[path[i]];
-
- if (edits.removeSharedTitle) {
- delete targetNode.sharedTitle;
- }
-
- if (edits.title !== undefined) {
- targetNode.title = edits.title;
- const enTitleIndex = targetNode.titles?.findIndex(t => t.lang === "en" && t.primary);
- if (enTitleIndex >= 0) {
- targetNode.titles[enTitleIndex].text = edits.title;
- } else {
- if (!targetNode.titles) targetNode.titles = [];
- targetNode.titles.push({ text: edits.title, lang: "en", primary: true });
- }
- }
-
- if (edits.heTitle !== undefined) {
- targetNode.heTitle = edits.heTitle;
- const heTitleIndex = targetNode.titles?.findIndex(t => t.lang === "he" && t.primary);
- if (heTitleIndex >= 0) {
- targetNode.titles[heTitleIndex].text = edits.heTitle;
- } else {
- if (!targetNode.titles) targetNode.titles = [];
- targetNode.titles.push({ text: edits.heTitle, lang: "he", primary: true });
- }
- }
- } else {
- node = node.nodes[path[i]];
- }
- }
- });
-
- // Save
- const url = `/api/v2/raw/index/${encodeURIComponent(indexTitle.replace(/ /g, "_"))}`;
- await $.ajax({
- url,
- type: 'POST',
- data: { json: JSON.stringify(updatedIndex) },
- dataType: 'json'
- });
-
- await $.get(`/admin/reset/${encodeURIComponent(indexTitle)}`);
-
- setMsg(`✅ Successfully updated node titles`);
- setEditingNodes({});
-
- // Reload
- setTimeout(() => load(), 1000);
- } catch (e) {
- let errorMsg = e.responseJSON?.error || e.responseText || 'Unknown error';
- setMsg(`❌ Error: ${errorMsg}`);
- }
-
- setSaving(false);
- };
-
- const nodes = indexData ? extractNodes(indexData.schema || indexData) : [];
- // Check for validation errors - title_valid must be explicitly true to pass
- const hasValidationErrors = Object.values(editingNodes).some(edits => edits.title !== undefined && edits.title_valid !== true);
- const hasChanges = Object.keys(editingNodes).length > 0;
-
- return (
-
- {/* Search bar */}
-
- setIndexTitle(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && load()}
- />
-
- {loading ? <> Loading...> : "Load Index"}
-
-
-
- {nodes.length > 0 && (
- <>
-
- Found {nodes.length} nodes. Edit titles below:
-
-
- {/* Validation warning */}
-
-
Important:
-
- English titles: ASCII only. No periods, hyphens, colons, slashes.
- Hebrew titles: Can be changed freely.
- Main index titles: May affect dependent texts.
-
-
-
- {/* Dependency warning */}
- {dependencies?.has_dependencies && (
-
-
Dependency Warning for "{indexTitle}":
-
- {dependencies.dependent_count > 0 && (
- {dependencies.dependent_count} dependent texts: {" "}
- {dependencies.dependent_indices.slice(0, 5).join(', ')}
- {dependencies.dependent_indices.length > 5 ? '...' : ''}
-
- )}
- {dependencies.version_count > 0 && (
- {dependencies.version_count} versions reference this index
- )}
- {dependencies.link_count > 0 && (
- {dependencies.link_count} links reference this text
- )}
-
-
- )}
-
- {checkingDeps && (
- Checking dependencies...
- )}
-
- {/* Node list */}
-
- {nodes.map(({ path, pathStr, node, sharedTitle, title, heTitle }) => {
- const edits = editingNodes[pathStr] || {};
- const nodeHasChanges = edits.title !== undefined || edits.heTitle !== undefined || edits.removeSharedTitle;
-
- return (
-
- {sharedTitle && (
-
- Shared Title: "{sharedTitle}"
-
- handleNodeEdit(pathStr, 'removeSharedTitle', e.target.checked)}
- />
- Remove shared title
-
-
- )}
-
-
-
-
English Title:
-
handleNodeEdit(pathStr, 'title', e.target.value)}
- />
- {edits.title !== undefined && edits.title_valid === false && (
-
- Invalid: ASCII only, no special characters
-
- )}
-
-
-
Hebrew Title:
-
handleNodeEdit(pathStr, 'heTitle', e.target.value)}
- style={{ direction: "rtl" }}
- />
-
-
-
- {node.nodeType && (
-
- Type: {node.nodeType} | Path: nodes[{path.join('][')}]
-
- )}
-
- );
- })}
-
-
- {hasChanges && (
-
-
- {saving ? <> Saving...> :
- hasValidationErrors ? "Fix validation errors to save" :
- `Save Changes to ${Object.keys(editingNodes).length} Nodes`}
-
-
- )}
- >
- )}
-
-
-
- );
-};
-
-export default NodeTitleEditor;
diff --git a/static/js/modtools/index.js b/static/js/modtools/index.js
index d94c56b866..110cbbf94b 100644
--- a/static/js/modtools/index.js
+++ b/static/js/modtools/index.js
@@ -13,10 +13,6 @@
* - RemoveLinksFromCsv: Delete links from CSV
* - BulkVersionEditor: Edit Version metadata across multiple indices
*
- * NOTE: The following components are temporarily disabled (open tickets to reintroduce):
- * - BulkIndexEditor: Edit Index metadata across multiple indices
- * - AutoLinkCommentaryTool: Create links between commentaries and base texts
- * - NodeTitleEditor: Edit node titles within an Index schema
*
* For AI agents:
* - See /docs/modtools/AI_AGENT_GUIDE.md for detailed documentation
@@ -36,11 +32,6 @@ export { default as DownloadLinks } from './components/DownloadLinks';
export { default as RemoveLinksFromCsv } from './components/RemoveLinksFromCsv';
export { default as BulkVersionEditor } from './components/BulkVersionEditor';
-// TODO: The following exports are temporarily disabled - open tickets to reintroduce:
-// export { default as BulkIndexEditor } from './components/BulkIndexEditor';
-// export { default as AutoLinkCommentaryTool } from './components/AutoLinkCommentaryTool';
-// export { default as NodeTitleEditor } from './components/NodeTitleEditor';
-
// Shared components
export * from './components/shared';
From 5518adaaa9ab3ba2d4a25b4db0e6cfa97774d48b Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Wed, 14 Jan 2026 14:27:30 +0200
Subject: [PATCH 18/26] refactor(modtools): Remove unused code for disabled
tools
Remove code that is only used by disabled tool components:
- check_index_dependencies_api from views.py (used by NodeTitleEditor)
- URL route for check-index-dependencies from urls.py
- TestCheckIndexDependenciesAPI from modtools_test.py
- INDEX_FIELD_METADATA from fieldMetadata.js (used by BulkIndexEditor)
- BASE_TEXT_MAPPING_OPTIONS from fieldMetadata.js (used by AutoLinkCommentaryTool)
This code will be added to the appropriate feature branches:
- sc-36477: NodeTitleEditor
- sc-36473: BulkIndexEditor
- sc-36476: AutoLinkCommentaryTool
Co-Authored-By: Claude Opus 4.5
---
sefaria/tests/modtools_test.py | 19 ---
sefaria/urls.py | 1 -
sefaria/views.py | 40 -----
static/js/modtools/constants/fieldMetadata.js | 139 +-----------------
4 files changed, 1 insertion(+), 198 deletions(-)
diff --git a/sefaria/tests/modtools_test.py b/sefaria/tests/modtools_test.py
index 7edbec6616..e762c8ce5b 100644
--- a/sefaria/tests/modtools_test.py
+++ b/sefaria/tests/modtools_test.py
@@ -324,25 +324,6 @@ def test_bulk_edit_without_language_parameter(self, staff_client):
v.delete()
-class TestCheckIndexDependenciesAPI:
- """Tests for /api/check-index-dependencies endpoint."""
-
- @pytest.mark.django_db
- def test_check_dependencies_requires_staff(self, regular_client):
- """Non-staff users should be denied access."""
- response = regular_client.get('/api/check-index-dependencies/Genesis')
- assert response.status_code in [302, 403]
-
- @pytest.mark.django_db
- def test_check_dependencies_returns_info(self, staff_client):
- """Should return dependency information for valid index."""
- response = staff_client.get('/api/check-index-dependencies/Genesis')
- assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.content}"
- data = json.loads(response.content)
- assert 'has_dependencies' in data, "Response missing 'has_dependencies' field"
- assert 'dependent_indices' in data, "Response missing 'dependent_indices' field"
-
-
# ============================================================================
# Legacy Modtools API Tests (Priority 1 - Write Operations)
# ============================================================================
diff --git a/sefaria/urls.py b/sefaria/urls.py
index bcb07f5890..8cbcff06a9 100644
--- a/sefaria/urls.py
+++ b/sefaria/urls.py
@@ -161,7 +161,6 @@
url(r'^api/versions/?$', reader_views.complete_version_api),
url(r'^api/version-indices$', sefaria_views.version_indices_api),
url(r'^api/version-bulk-edit$', sefaria_views.version_bulk_edit_api),
- url(r'^api/check-index-dependencies/(?P.+)$', sefaria_views.check_index_dependencies_api),
url(r'^api/v3/texts/(?P.+)$', api_views.Text.as_view()),
url(r'^api/index/?$', reader_views.table_of_contents_api),
url(r'^api/opensearch-suggestions/?$', reader_views.opensearch_suggestions_api),
diff --git a/sefaria/views.py b/sefaria/views.py
index 25718ecc81..d02f0e21d9 100644
--- a/sefaria/views.py
+++ b/sefaria/views.py
@@ -1726,46 +1726,6 @@ def version_bulk_edit_api(request):
return jsonResponse(result)
-@staff_member_required
-def check_index_dependencies_api(request, title):
- """
- Check what dependencies exist for a given index title.
- Used by NodeTitleEditor to warn about potential impacts of title changes.
-
- NOTE: NodeTitleEditor is currently disabled in ModeratorToolsPanel.
- This endpoint is not in active use and should be reviewd when used but retained for future re-enablement.
- """
- if request.method != "GET":
- return jsonResponse({"error": "GET required"})
-
- try:
- # Get dependent indices (commentaries, etc.)
- dependent_indices = library.get_dependant_indices(title, full_records=False)
-
- # Get version count
- version_count = db.texts.count_documents({"title": title})
-
- # Get link count (approximate)
- from sefaria.model.text import prepare_index_regex_for_dependency_process # Inline import: this specific function is not exported via sefaria.model wildcard
- try:
- index = library.get_index(title)
- pattern = prepare_index_regex_for_dependency_process(index)
- link_count = db.links.count_documents({"refs": {"$regex": pattern}})
- except Exception as e:
- logger.debug(f"Failed to get link count for {title}: {e}")
- link_count = 0
-
- return jsonResponse({
- "title": title,
- "dependent_indices": dependent_indices,
- "version_count": version_count,
- "link_count": link_count,
- "has_dependencies": len(dependent_indices) > 0 or version_count > 0 or link_count > 0
- })
-
- except Exception as e:
- return jsonResponse({"error": str(e)})
-
@staff_member_required
def update_authors_from_sheet(request):
from sefaria.helper.descriptions import update_authors_data
diff --git a/static/js/modtools/constants/fieldMetadata.js b/static/js/modtools/constants/fieldMetadata.js
index db139dffae..2a8e7c0340 100644
--- a/static/js/modtools/constants/fieldMetadata.js
+++ b/static/js/modtools/constants/fieldMetadata.js
@@ -1,5 +1,5 @@
/**
- * Field metadata definitions for Index and Version editing tools.
+ * Field metadata definitions for Version editing tools.
*
* This file centralizes the field configuration for bulk editing operations,
* making it easier to maintain consistency across the modtools components.
@@ -10,135 +10,11 @@
* - select: Dropdown with predefined options
* - array: Comma-separated list that converts to array
* - number: Numeric input with optional min/max
- * - daterange: Date or date range (year or [start, end] format)
*
* For AI agents: When adding new fields, ensure the backend model
* (sefaria/model/text.py) supports the field as an optional_attr.
*/
-/**
- * INDEX_FIELD_METADATA
- *
- * Defines editable fields for Index records (text metadata).
- * These correspond to fields in the Index model (sefaria/model/text.py).
- */
-export const INDEX_FIELD_METADATA = {
- "enDesc": {
- label: "English Description",
- type: "textarea",
- placeholder: "A description of the text in English"
- },
- "enShortDesc": {
- label: "Short English Description",
- type: "textarea",
- placeholder: "Brief description (1-2 sentences)"
- },
- "heDesc": {
- label: "Hebrew Description",
- type: "textarea",
- placeholder: "תיאור הטקסט בעברית",
- dir: "rtl"
- },
- "heShortDesc": {
- label: "Hebrew Short Description",
- type: "textarea",
- placeholder: "תיאור קצר (משפט או שניים)",
- dir: "rtl"
- },
- "categories": {
- label: "Category",
- type: "array",
- placeholder: "Select category...",
- help: "The category path determines where this text appears in the library"
- },
- "authors": {
- label: "Authors",
- type: "array",
- placeholder: "Author names (one per line or comma-separated)",
- help: "Enter author names. Backend expects a list of strings. Use 'auto' to detect from title.",
- auto: true
- },
- "compDate": {
- label: "Composition Date",
- type: "daterange",
- placeholder: "[1040, 1105] or 1105 or -500",
- help: "Year or range [start, end]. Negative for BCE. Arrays auto-convert to single year if identical."
- },
- "compPlace": {
- label: "Composition Place",
- type: "text",
- placeholder: "e.g., 'Troyes, France'"
- },
- "heCompPlace": {
- label: "Hebrew Composition Place",
- type: "text",
- placeholder: "למשל: 'טרואה, צרפת'",
- dir: "rtl"
- },
- "pubDate": {
- label: "Publication Date",
- type: "daterange",
- placeholder: "[1475, 1475] or 1475",
- help: "First publication year or range"
- },
- "pubPlace": {
- label: "Publication Place",
- type: "text",
- placeholder: "e.g., 'Venice, Italy'"
- },
- "hePubPlace": {
- label: "Hebrew Publication Place",
- type: "text",
- placeholder: "למשל: 'ונציה, איטליה'",
- dir: "rtl"
- },
- "toc_zoom": {
- label: "TOC Zoom Level",
- type: "number",
- placeholder: "0-10",
- help: "Controls how deep the table of contents displays by default (0=fully expanded). Must be an integer.",
- validate: (value) => {
- if (value === "" || value === null || value === undefined) return true;
- const num = parseInt(value);
- return !isNaN(num) && num >= 0 && num <= 10;
- }
- },
- "dependence": {
- label: "Dependence Type",
- type: "select",
- placeholder: "Select dependence type",
- help: "Is this text dependent on another text? (e.g., Commentary on a base text)",
- options: [
- { value: "", label: "None" },
- { value: "Commentary", label: "Commentary" },
- { value: "Targum", label: "Targum" },
- { value: "auto", label: "Auto-detect from title" }
- ],
- auto: true
- },
- "base_text_titles": {
- label: "Base Text Titles",
- type: "array",
- placeholder: "Base text names (one per line or comma-separated)",
- help: "Enter base text names that this commentary depends on. Use 'auto' to detect from title (e.g., 'Genesis' for 'Rashi on Genesis'). Backend expects a list of strings.",
- auto: true
- },
- "collective_title": {
- label: "English Collective Title",
- type: "text",
- placeholder: "Collective title or 'auto' for auto-detection",
- help: "Enter collective title or type 'auto' to detect from title (e.g., 'Rashi' for 'Rashi on Genesis'). If Hebrew equivalent is provided, term will be created automatically.",
- auto: true
- },
- "he_collective_title": {
- label: "Hebrew Collective Title (Term)",
- type: "text",
- placeholder: "Hebrew equivalent of collective title",
- help: "Hebrew equivalent of the collective title. If the term doesn't exist, it will be created automatically with both English and Hebrew titles.",
- dir: "rtl"
- }
-};
-
/**
* VERSION_FIELD_METADATA
*
@@ -263,16 +139,3 @@ export const VERSION_FIELD_METADATA = {
]
}
};
-
-/**
- * BASE_TEXT_MAPPING_OPTIONS
- *
- * Options for the base_text_mapping field used in commentary linking.
- * See sefaria/model/link.py for implementation details.
- */
-export const BASE_TEXT_MAPPING_OPTIONS = [
- { value: "many_to_one_default_only", label: "many_to_one_default_only (Mishnah / Tanakh)" },
- { value: "many_to_one", label: "many_to_one" },
- { value: "one_to_one_default_only", label: "one_to_one_default_only" },
- { value: "one_to_one", label: "one_to_one" }
-];
From b035ffb010007ed6da87c04f89dbf3ffda8b46d7 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Thu, 15 Jan 2026 14:40:36 +0200
Subject: [PATCH 19/26] chore: trigger CI
Co-Authored-By: Claude Opus 4.5
From 12bf38b2473c43e2b1cbf13f786eca0f2260b3df Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Sun, 18 Jan 2026 16:59:42 +0200
Subject: [PATCH 20/26] chore: trigger CI
Co-Authored-By: Claude Opus 4.5
From 1222981f18746c9ae589c91ac93ba8807c705ba9 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Mon, 19 Jan 2026 18:21:42 +0200
Subject: [PATCH 21/26] refactor: address PR review - use existing stripHtml
and move CSS to static.css
- Replace custom stripHtmlTags utility with existing String.prototype.stripHtml()
from sefaria/util.js (addresses PR comment about duplicate code)
- Move modtools.css content into static.css and remove separate file
(addresses PR comment about CSS file organization)
- Remove now-empty modtools/utils directory
- Update base.html to remove modtools.css link tag
Co-Authored-By: Claude Opus 4.5
---
static/css/modtools.css | 1652 -----------------
static/css/static.css | 1652 +++++++++++++++++
static/js/ModeratorToolsPanel.jsx | 4 +-
.../components/RemoveLinksFromCsv.jsx | 3 +-
.../components/UploadLinksFromCSV.jsx | 3 +-
.../components/WorkflowyModeratorTool.jsx | 3 +-
static/js/modtools/utils/index.js | 7 -
static/js/modtools/utils/stripHtmlTags.js | 21 -
templates/base.html | 2 -
9 files changed, 1656 insertions(+), 1691 deletions(-)
delete mode 100644 static/css/modtools.css
delete mode 100644 static/js/modtools/utils/index.js
delete mode 100644 static/js/modtools/utils/stripHtmlTags.js
diff --git a/static/css/modtools.css b/static/css/modtools.css
deleted file mode 100644
index 0bc45026ed..0000000000
--- a/static/css/modtools.css
+++ /dev/null
@@ -1,1652 +0,0 @@
-/**
- * ModTools Design System
- * ======================
- * A refined admin dashboard with scholarly character.
- *
- * STRUCTURE:
- * 1. Design Tokens (CSS Variables)
- * 2. Base & Reset
- * 3. Layout Components (Section, Cards)
- * 4. Form Elements (Inputs, Selects, Buttons)
- * 5. Layout Patterns (SearchRow, FilterRow, ActionRow)
- * 6. Data Display (IndexSelector, NodeList)
- * 7. Feedback (Alerts, Messages, Status)
- * 8. Utilities
- * 9. Responsive
- *
- * NAMING CONVENTION:
- * - All classes prefixed with context (e.g., .modTools .searchRow)
- * - Variables prefixed with --mt- (mod tools)
- * - BEM-lite: .component, .component-element, .component.modifier
- */
-
-/* Google Fonts - Scholarly + Modern pairing */
-@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
-
-/* ==========================================================================
- 1. DESIGN TOKENS
- ========================================================================== */
-:root {
- /*
- * COLOR PALETTE
- * Using Sefaria design system colors from s2.css
- */
-
- /* Background colors */
- --mt-bg-page: var(--lightest-grey); /* Main page background */
- --mt-bg-card: #FFFFFF; /* Card/section background */
- --mt-bg-subtle: var(--lighter-grey); /* Subtle background for groupings */
- --mt-bg-input: var(--lightest-grey); /* Input field background */
- --mt-bg-hover: var(--lighter-grey); /* Hover state background */
-
- /* Brand colors */
- --mt-primary: var(--sefaria-blue); /* Primary actions, headings */
- --mt-primary-hover: #122B4A; /* Primary hover state (darker sefaria-blue) */
- --mt-primary-light: rgba(24, 52, 93, 0.08); /* Primary tint for backgrounds */
- --mt-accent: var(--inline-link-blue); /* Accent/links */
- --mt-accent-hover: #3A5FA6; /* Accent hover */
- --mt-accent-light: rgba(72, 113, 191, 0.1); /* Accent tint */
-
- /* Text colors */
- --mt-text: var(--darkest-grey); /* Primary text */
- --mt-text-secondary: var(--dark-grey); /* Secondary/supporting text */
- --mt-text-muted: var(--medium-grey); /* Muted/placeholder text */
- --mt-text-on-primary: #FFFFFF; /* Text on primary color */
-
- /* Border colors */
- --mt-border: var(--lighter-grey); /* Default border */
- --mt-border-hover: var(--light-grey); /* Border on hover */
- --mt-border-focus: var(--mt-primary); /* Border on focus */
-
- /* Status colors - Success */
- --mt-success: #059669;
- --mt-success-bg: #ECFDF5;
- --mt-success-border: #A7F3D0;
- --mt-success-text: #065F46;
-
- /* Status colors - Warning */
- --mt-warning: #D97706;
- --mt-warning-bg: #FFFBEB;
- --mt-warning-border: #FDE68A;
- --mt-warning-text: #92400E;
-
- /* Status colors - Error */
- --mt-error: #DC2626;
- --mt-error-bg: #FEF2F2;
- --mt-error-border: #FECACA;
- --mt-error-text: #991B1B;
-
- /* Status colors - Info */
- --mt-info: #0891B2;
- --mt-info-bg: #ECFEFF;
- --mt-info-border: #A5F3FC;
- --mt-info-text: #0E7490;
-
- /*
- * TYPOGRAPHY
- */
-
- /* Font families */
- --mt-font-display: "Crimson Pro", "Georgia", serif;
- --mt-font-body: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif;
- --mt-font-hebrew: "Heebo", "Arial Hebrew", sans-serif;
- --mt-font-mono: "JetBrains Mono", "Fira Code", monospace;
-
- /* Font sizes - Using a modular scale */
- --mt-text-xs: 11px; /* Small labels, badges */
- --mt-text-sm: 13px; /* Help text, meta */
- --mt-text-base: 15px; /* Body text */
- --mt-text-md: 16px; /* Slightly larger body */
- --mt-text-lg: 18px; /* Section intros */
- --mt-text-xl: 20px; /* Mobile headings */
- --mt-text-2xl: 24px; /* Section titles */
-
- /* Font weights */
- --mt-font-normal: 400;
- --mt-font-medium: 500;
- --mt-font-semibold: 600;
- --mt-font-bold: 700;
-
- /* Line heights */
- --mt-leading-tight: 1.3;
- --mt-leading-normal: 1.5;
- --mt-leading-relaxed: 1.6;
-
- /*
- * SPACING
- * Based on 4px grid - Compact design
- */
- --mt-space-xs: 2px; /* Tight spacing */
- --mt-space-sm: 4px; /* Small gaps */
- --mt-space-md: 8px; /* Medium gaps, default padding */
- --mt-space-lg: 12px; /* Large gaps, section spacing */
- --mt-space-xl: 16px; /* Extra large, card padding */
- --mt-space-2xl: 24px; /* Major section breaks */
-
- /*
- * BORDERS & EFFECTS
- */
-
- /* Border radius */
- --mt-radius-sm: 6px; /* Small elements, badges */
- --mt-radius-md: 10px; /* Inputs, buttons */
- --mt-radius-lg: 14px; /* Cards, sections */
-
- /* Border width */
- --mt-border-width: 1px;
- --mt-border-width-thick: 1.5px;
- --mt-border-width-heavy: 2px;
-
- /* Shadows */
- --mt-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
- --mt-shadow-card: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
- --mt-shadow-elevated: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
- --mt-shadow-focus: 0 0 0 4px var(--mt-primary-light);
-
- /*
- * ANIMATION
- */
- --mt-transition-fast: 100ms cubic-bezier(0.4, 0, 0.2, 1);
- --mt-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
- --mt-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
-
- /*
- * LAYOUT
- */
- --mt-max-width: 1100px;
- --mt-input-height: 36px; /* Standard input height - compact */
-}
-
-/* ==========================================================================
- 2. BASE & RESET
- ========================================================================== */
-.modTools {
- width: 100%;
- min-height: 100vh;
- padding: var(--mt-space-lg) var(--mt-space-md);
- padding-bottom: 40px; /* Bottom margin */
- background: var(--mt-bg-page);
- font-family: var(--mt-font-body);
- font-size: var(--mt-text-sm);
- line-height: var(--mt-leading-normal);
- color: var(--mt-text);
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- box-sizing: border-box;
-}
-
-/*
- * Inner container with max-width.
- * The outer .modTools is full-width so users can scroll from the side margins
- * without inner scrollable elements (like .indexList) capturing the scroll.
- */
-.modTools .modToolsInner {
- max-width: var(--mt-max-width);
- margin: 0 auto;
-}
-
-.modTools *,
-.modTools *::before,
-.modTools *::after {
- box-sizing: border-box;
-}
-
-/* Page header */
-.modTools .modToolsInner::before {
- content: "Moderator Tools";
- display: block;
- font-family: var(--mt-font-display);
- font-size: 22px;
- font-weight: var(--mt-font-semibold);
- color: var(--mt-primary);
- padding: var(--mt-space-md) 0;
- margin-bottom: var(--mt-space-lg);
- border-bottom: 2px solid var(--mt-border);
- letter-spacing: -0.01em;
-}
-
-/* ==========================================================================
- 3. LAYOUT COMPONENTS
- ========================================================================== */
-
-/* --- Section Cards --- */
-.modTools .modToolsSection {
- background: var(--mt-bg-card);
- border-radius: var(--mt-radius-sm);
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-sm);
- box-shadow: var(--mt-shadow-sm);
- border: 1px solid var(--mt-border);
- transition: box-shadow var(--mt-transition);
- overflow: visible;
-}
-
-.modTools .modToolsSection:hover {
- box-shadow: var(--mt-shadow-elevated);
-}
-
-/* Section Title */
-.modTools .dlSectionTitle {
- font-family: var(--mt-font-display);
- font-size: var(--mt-text-lg);
- font-weight: var(--mt-font-semibold);
- color: var(--mt-primary);
- margin: 0 0 var(--mt-space-xs) 0;
- padding-bottom: var(--mt-space-sm);
- border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
- letter-spacing: -0.01em;
- line-height: var(--mt-leading-tight);
-}
-
-.modTools .dlSectionTitle .int-he {
- font-family: var(--mt-font-hebrew);
- margin-left: var(--mt-space-md);
- font-size: var(--mt-text-lg);
- color: var(--mt-text-secondary);
-}
-
-/* Section subtitle/description */
-.modTools .sectionDescription {
- font-size: 13px;
- color: var(--mt-text-secondary);
- margin-bottom: var(--mt-space-md);
- line-height: var(--mt-leading-normal);
-}
-
-/* ==========================================================================
- 4. FORM ELEMENTS
- ========================================================================== */
-
-/* --- Labels --- */
-.modTools label {
- display: block;
- font-size: 13px;
- font-weight: var(--mt-font-medium);
- color: var(--mt-text);
- margin-bottom: var(--mt-space-xs);
-}
-
-/* --- Input Base Styles --- */
-.modTools .dlVersionSelect,
-.modTools input[type="text"],
-.modTools input[type="number"],
-.modTools input[type="url"],
-.modTools select,
-.modTools textarea {
- display: block;
- width: 100%;
- max-width: 100%;
- padding: 6px 10px;
- margin-bottom: var(--mt-space-sm);
- font-family: var(--mt-font-body);
- font-size: var(--mt-text-sm);
- line-height: var(--mt-leading-normal);
- color: var(--mt-text);
- background: var(--mt-bg-input);
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-sm);
- transition: all var(--mt-transition);
- box-sizing: border-box;
-}
-
-.modTools .dlVersionSelect::placeholder,
-.modTools input::placeholder,
-.modTools textarea::placeholder {
- color: var(--mt-text-muted);
-}
-
-.modTools .dlVersionSelect:hover,
-.modTools input[type="text"]:hover,
-.modTools input[type="number"]:hover,
-.modTools input[type="url"]:hover,
-.modTools select:hover,
-.modTools textarea:hover {
- border-color: var(--mt-border-hover);
- background: var(--mt-bg-card);
-}
-
-.modTools .dlVersionSelect:focus,
-.modTools input[type="text"]:focus,
-.modTools input[type="number"]:focus,
-.modTools input[type="url"]:focus,
-.modTools select:focus,
-.modTools textarea:focus {
- outline: none;
- border-color: var(--mt-border-focus);
- background: var(--mt-bg-card);
- box-shadow: var(--mt-shadow-focus);
-}
-
-/* Select dropdowns - Clear arrow indicator */
-.modTools select,
-.modTools select.dlVersionSelect {
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
- background-color: var(--mt-bg-input);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2318345D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 14px center;
- background-size: 14px 14px;
- padding-right: 44px !important;
- cursor: pointer;
-}
-
-/* --- Textarea --- */
-.modTools textarea {
- min-height: 100px;
- resize: vertical;
- font-family: var(--mt-font-body);
- font-size: 14px;
- line-height: var(--mt-leading-relaxed);
-}
-
-/* ==========================================================================
- 5. LAYOUT PATTERNS
- ========================================================================== */
-
-/* --- Input Row (legacy, stacked) --- */
-.modTools .inputRow {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-md);
- margin-bottom: var(--mt-space-lg);
-}
-
-.modTools .inputRow input,
-.modTools .inputRow select {
- margin-bottom: 0;
-}
-
-/* Search row - Input + Button inline */
-.modTools .searchRow {
- display: flex;
- gap: var(--mt-space-md);
- align-items: flex-start;
- margin-bottom: var(--mt-space-lg);
-}
-
-.modTools .searchRow input {
- flex: 1;
- margin-bottom: 0;
-}
-
-.modTools .searchRow .modtoolsButton {
- flex-shrink: 0;
- white-space: nowrap;
- /* Standard button sizing - not stretched */
- padding: 6px 14px;
- min-width: auto;
- width: auto;
-}
-
-/* Filter row - label inline with dropdown */
-.modTools .filterRow {
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
- margin-bottom: var(--mt-space-lg);
-}
-
-.modTools .filterRow label {
- margin-bottom: 0;
- white-space: nowrap;
-}
-
-.modTools .filterRow select {
- margin-bottom: 0;
- max-width: 200px;
-}
-
-/* Clear search button - centered */
-.modTools .clearSearchRow {
- display: flex;
- justify-content: center;
- margin-bottom: var(--mt-space-lg);
-}
-
-/* Action button row - for primary action buttons */
-.modTools .actionRow {
- display: flex;
- gap: var(--mt-space-md);
- align-items: center;
- flex-wrap: wrap;
- margin-top: var(--mt-space-lg);
-}
-
-/* Separator before delete section */
-.modTools .deleteSectionSeparator {
- margin-top: var(--mt-space-xl);
- margin-bottom: var(--mt-space-md);
- border-top: 1px solid var(--mt-border);
-}
-
-/* Section intro text - for counts, descriptions */
-.modTools .sectionIntro {
- margin-bottom: var(--mt-space-md);
- font-size: 15px;
- font-weight: 500;
- color: var(--mt-text);
-}
-
-/* Subsection heading */
-.modTools .subsectionHeading {
- margin-top: var(--mt-space-lg);
- margin-bottom: var(--mt-space-md);
- font-size: 15px;
- font-weight: 500;
- color: var(--mt-text);
-}
-
-/* Option row - for single option with label */
-.modTools .optionRow {
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
- margin-bottom: var(--mt-space-md);
-}
-
-.modTools .optionRow label {
- margin-bottom: 0;
- white-space: nowrap;
- min-width: fit-content;
-}
-
-.modTools .optionRow select,
-.modTools .optionRow input {
- margin-bottom: 0;
- flex: 1;
- max-width: 300px;
-}
-
-/* Node list container - for scrollable lists */
-.modTools .nodeListContainer {
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-md);
- padding: var(--mt-space-md);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-bg-card);
-}
-
-/* Node item - for individual editable items */
-.modTools .nodeItem {
- margin-bottom: var(--mt-space-md);
- padding: var(--mt-space-md);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-sm);
- border: 1px solid var(--mt-border);
- transition: all var(--mt-transition);
-}
-
-.modTools .nodeItem:last-child {
- margin-bottom: 0;
-}
-
-.modTools .nodeItem.modified {
- background: var(--mt-warning-bg);
- border-color: var(--mt-warning-border);
-}
-
-.modTools .nodeItem .nodeGrid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: var(--mt-space-md);
-}
-
-.modTools .nodeItem .nodeMeta {
- margin-top: var(--mt-space-sm);
- font-size: 12px;
- color: var(--mt-text-muted);
-}
-
-.modTools .nodeItem .nodeSharedTitle {
- margin-bottom: var(--mt-space-sm);
- font-size: 13px;
- color: var(--mt-text-secondary);
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
-}
-
-/* Small label for form fields */
-.modTools .fieldLabel {
- font-size: 13px;
- color: var(--mt-text-secondary);
- margin-bottom: var(--mt-space-xs);
- font-weight: 500;
-}
-
-/* Validation error on input */
-.modTools input.hasError,
-.modTools select.hasError {
- border-color: var(--mt-error) !important;
- background: var(--mt-error-bg);
-}
-
-.modTools .validationHint {
- font-size: var(--mt-text-sm);
- color: var(--mt-error);
- margin-top: var(--mt-space-xs);
-}
-
-/* --- Two-column grid for related fields --- */
-.modTools .formGrid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: var(--mt-space-md);
-}
-
-.modTools .formGrid.fullWidth {
- grid-column: 1 / -1;
-}
-
-/* ==========================================================================
- 6. BUTTONS
- ========================================================================== */
-
-/* --- Primary Button --- */
-.modTools .modtoolsButton {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--mt-space-xs);
- padding: 6px 14px;
- background: var(--mt-primary);
- color: var(--mt-text-on-primary);
- font-family: var(--mt-font-body);
- font-size: 12px;
- font-weight: 600;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- transition: all var(--mt-transition);
- text-decoration: none;
- white-space: nowrap;
- text-align: center;
-}
-
-.modTools .modtoolsButton:hover {
- background: var(--mt-primary-hover);
- transform: translateY(-1px);
- box-shadow: var(--mt-shadow-sm);
-}
-
-.modTools .modtoolsButton:active {
- transform: translateY(0);
-}
-
-.modTools .modtoolsButton:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- transform: none;
-}
-
-/* Secondary button */
-.modTools .modtoolsButton.secondary {
- background: transparent;
- color: var(--mt-primary);
- border: 2px solid var(--mt-primary);
- padding: 6px 14px;
-}
-
-.modTools .modtoolsButton.secondary:hover {
- background: var(--mt-primary-light);
-}
-
-/* Danger button */
-.modTools .modtoolsButton.danger {
- background: var(--mt-error);
-}
-
-.modTools .modtoolsButton.danger:hover {
- background: #B91C1C;
-}
-
-/* Small button */
-.modTools .modtoolsButton.small {
- padding: 5px 10px;
- font-size: 12px;
-}
-
-/* Loading spinner in buttons */
-.modTools .loadingSpinner {
- display: inline-block;
- width: 16px;
- height: 16px;
- border: 2px solid currentColor;
- border-right-color: transparent;
- border-radius: 50%;
- animation: mt-spin 0.7s linear infinite;
-}
-
-@keyframes mt-spin {
- to { transform: rotate(360deg); }
-}
-
-/* Button row */
-.modTools .buttonRow {
- display: flex;
- gap: var(--mt-space-md);
- flex-wrap: wrap;
- margin-top: var(--mt-space-lg);
-}
-
-/* --- File Upload Zones --- */
-.modTools input[type="file"] {
- display: block;
- width: 100%;
- padding: var(--mt-space-md);
- margin-bottom: var(--mt-space-sm);
- background: var(--mt-bg-subtle);
- border: 2px dashed var(--mt-border);
- border-radius: var(--mt-radius-sm);
- cursor: pointer;
- font-family: var(--mt-font-body);
- font-size: 13px;
- color: var(--mt-text-secondary);
-}
-
-.modTools input[type="file"]:hover {
- border-color: var(--mt-primary);
- background: var(--mt-primary-light);
-}
-
-.modTools input[type="file"]::file-selector-button {
- padding: 6px 12px;
- margin-right: var(--mt-space-sm);
- background: var(--mt-primary);
- color: white;
- border: none;
- border-radius: var(--mt-radius-sm);
- font-family: var(--mt-font-body);
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- transition: background var(--mt-transition);
-}
-
-.modTools input[type="file"]::file-selector-button:hover {
- background: var(--mt-primary-hover);
-}
-
-/* ==========================================================================
- 7. DATA DISPLAY COMPONENTS
- ========================================================================== */
-
-/* --- Index Selector --- */
-.modTools .indexSelectorContainer {
- margin-top: var(--mt-space-lg);
- background: var(--mt-bg-card);
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-lg);
- overflow: hidden;
-}
-
-/* Header */
-.modTools .indexSelectorHeader {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: var(--mt-space-md) var(--mt-space-lg);
- background: var(--mt-bg-subtle);
- border-bottom: 1px solid var(--mt-border);
- flex-wrap: wrap;
- gap: var(--mt-space-md);
-}
-
-.modTools .indexSelectorTitle {
- font-size: 16px;
- font-weight: 500;
- color: var(--mt-text);
-}
-
-.modTools .indexSelectorTitle .highlight {
- color: var(--mt-primary);
- font-weight: 700;
-}
-
-.modTools .indexSelectorActions {
- display: flex;
- align-items: center;
- gap: var(--mt-space-lg);
-}
-
-.modTools .selectionCount {
- font-size: 14px;
- color: var(--mt-text-secondary);
-}
-
-.modTools .selectAllToggle {
- display: flex;
- align-items: center;
- gap: var(--mt-space-sm);
- font-size: 14px;
- font-weight: 500;
- color: var(--mt-text);
- cursor: pointer;
- margin-bottom: 0;
-}
-
-.modTools .selectAllToggle input[type="checkbox"] {
- width: 18px;
- height: 18px;
- accent-color: var(--mt-primary);
- cursor: pointer;
-}
-
-/* Search input */
-.modTools .indexSearchWrapper {
- position: relative;
- padding: var(--mt-space-md) var(--mt-space-lg);
- background: var(--mt-bg-card);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .indexSearchInput {
- width: 100%;
- padding: 10px 40px 10px 16px;
- margin: 0;
- font-size: 14px;
- border: 1.5px solid var(--mt-border);
- border-radius: var(--mt-radius-md);
- background: var(--mt-bg-input);
-}
-
-.modTools .indexSearchInput:focus {
- outline: none;
- border-color: var(--mt-primary);
- box-shadow: 0 0 0 3px var(--mt-primary-light);
-}
-
-.modTools .indexSearchClear {
- position: absolute;
- right: calc(var(--mt-space-lg) + 12px);
- top: 50%;
- transform: translateY(-50%);
- width: 24px;
- height: 24px;
- border: none;
- background: transparent;
- color: var(--mt-text-muted);
- font-size: 18px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
-}
-
-.modTools .indexSearchClear:hover {
- background: var(--mt-bg-subtle);
- color: var(--mt-text);
-}
-
-/* Index List */
-.modTools .indexList {
- display: flex;
- flex-direction: column;
- max-height: 400px;
- overflow-y: auto;
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-md);
- background: var(--mt-bg-card);
-}
-
-/* Custom scrollbar for index list */
-.modTools .indexList::-webkit-scrollbar {
- width: 8px;
-}
-
-.modTools .indexList::-webkit-scrollbar-track {
- background: var(--mt-bg-subtle);
- border-radius: 4px;
-}
-
-.modTools .indexList::-webkit-scrollbar-thumb {
- background: var(--mt-border);
- border-radius: 4px;
-}
-
-.modTools .indexList::-webkit-scrollbar-thumb:hover {
- background: #B5B3AC;
-}
-
-/* Index List Row */
-.modTools .indexListRow {
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
- padding: var(--mt-space-sm) var(--mt-space-md);
- border-bottom: 1px solid var(--mt-border);
- cursor: pointer;
- transition: background var(--mt-transition);
-}
-
-.modTools .indexListRow:last-child {
- border-bottom: none;
-}
-
-.modTools .indexListRow:hover {
- background: var(--mt-primary-light);
-}
-
-.modTools .indexListRow.selected {
- background: rgba(24, 52, 93, 0.08);
- box-shadow: inset 3px 0 0 var(--mt-primary);
-}
-
-.modTools .indexListRow input[type="checkbox"] {
- flex-shrink: 0;
- width: 16px;
- height: 16px;
- accent-color: var(--mt-primary);
- cursor: pointer;
-}
-
-.modTools .indexListTitle {
- flex: 1;
- font-size: 14px;
- font-weight: 500;
- color: var(--mt-text);
- line-height: 1.4;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.modTools .indexListCategory {
- flex-shrink: 0;
- font-size: 12px;
- color: var(--mt-text-muted);
- padding: 2px 8px;
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-sm);
-}
-
-/* No results in search */
-.modTools .indexNoResults {
- text-align: center;
- padding: var(--mt-space-xl);
- color: var(--mt-text-muted);
- font-size: 14px;
-}
-
-/* Legacy indices list (fallback) */
-.modTools .selectionControls {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: var(--mt-space-md);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-md) var(--mt-radius-md) 0 0;
- border: 1px solid var(--mt-border);
- border-bottom: none;
-}
-
-.modTools .selectionButtons {
- display: flex;
- gap: var(--mt-space-sm);
-}
-
-.modTools .indicesList {
- max-height: 280px;
- overflow-y: auto;
- border: 1px solid var(--mt-border);
- border-radius: 0 0 var(--mt-radius-md) var(--mt-radius-md);
- padding: var(--mt-space-sm);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-bg-card);
- scroll-behavior: smooth;
-}
-
-.modTools .indicesList label {
- display: flex;
- align-items: center;
- padding: var(--mt-space-sm) var(--mt-space-md);
- margin: 2px 0;
- cursor: pointer;
- border-radius: var(--mt-radius-sm);
- transition: background var(--mt-transition);
- font-weight: 400;
- font-size: 14px;
-}
-
-.modTools .indicesList label:hover {
- background: var(--mt-bg-subtle);
-}
-
-.modTools .indicesList label.selected {
- background: var(--mt-primary-light);
- border-left: 3px solid var(--mt-primary);
-}
-
-.modTools .indicesList label input[type="checkbox"] {
- width: 18px;
- height: 18px;
- margin: 0 var(--mt-space-md) 0 0;
-}
-
-/* ==========================================================================
- 8. FIELD GROUPS
- ========================================================================== */
-.modTools .fieldGroup {
- margin-bottom: var(--mt-space-sm);
-}
-
-.modTools .fieldGroup label {
- margin-bottom: var(--mt-space-xs);
-}
-
-.modTools .fieldGroup .fieldInput {
- margin-bottom: var(--mt-space-xs);
-}
-
-.modTools .fieldGroup .fieldInput:disabled {
- background-color: #f5f5f5;
- color: #999;
- cursor: not-allowed;
- opacity: 0.6;
-}
-
-.modTools .fieldHelp {
- font-size: 13px;
- color: var(--mt-text-muted);
- margin-bottom: var(--mt-space-sm);
- line-height: 1.5;
-}
-
-/* Field group sections */
-.modTools .fieldGroupSection {
- margin-bottom: var(--mt-space-md);
- padding: var(--mt-space-sm) var(--mt-space-md);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-sm);
- border: 1px solid var(--mt-border);
-}
-
-.modTools .fieldGroupHeader {
- font-family: var(--mt-font-body);
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 1px;
- color: var(--mt-text-muted);
- margin-bottom: var(--mt-space-md);
- padding-bottom: var(--mt-space-sm);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .fieldGroupGrid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: var(--mt-space-lg) var(--mt-space-md);
- align-items: end; /* Align inputs at bottom of each row */
-}
-
-.modTools .fieldGroupGrid .fieldGroup {
- margin-bottom: 0;
-}
-
-.modTools .fieldGroup.fullWidth {
- grid-column: 1 / -1;
-}
-
-/* Field name badge */
-.modTools .fieldNameBadge {
- display: inline-block;
- font-family: var(--mt-font-mono);
- font-size: 11px;
- padding: 2px 6px;
- background: var(--mt-bg-subtle);
- border: 1px solid var(--mt-border);
- border-radius: 4px;
- color: var(--mt-text-muted);
- margin-left: var(--mt-space-sm);
- vertical-align: middle;
-}
-
-/* Validation states */
-.modTools .fieldGroup.hasError input,
-.modTools .fieldGroup.hasError select {
- border-color: var(--mt-error);
- background: var(--mt-error-bg);
-}
-
-.modTools .fieldError {
- font-size: var(--mt-text-sm);
- color: var(--mt-error);
- margin-top: var(--mt-space-xs);
-}
-
-.modTools .requiredIndicator {
- color: var(--mt-error);
- font-weight: bold;
- margin-left: 2px;
-}
-
-/* ==========================================================================
- 9. FEEDBACK & ALERTS
- ========================================================================== */
-.modTools .message {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-top: var(--mt-space-md);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
-}
-
-.modTools .message.success {
- background: var(--mt-success-bg);
- color: var(--mt-success);
- border: 1px solid var(--mt-success-border);
-}
-
-.modTools .message.warning {
- background: var(--mt-warning-bg);
- color: var(--mt-warning-text);
- border: 1px solid var(--mt-warning-border);
-}
-
-.modTools .message.error {
- background: var(--mt-error-bg);
- color: var(--mt-error);
- border: 1px solid var(--mt-error-border);
-}
-
-.modTools .message.info {
- background: var(--mt-info-bg);
- color: #0E7490;
- border: 1px solid var(--mt-info-border);
-}
-
-/* Info/Warning/Danger boxes */
-.modTools .infoBox {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-info-bg);
- border: 1px solid var(--mt-info-border);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
- color: #0E7490;
-}
-
-.modTools .infoBox strong {
- color: var(--mt-info);
-}
-
-.modTools .warningBox {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-warning-bg);
- border: 1px solid var(--mt-warning-border);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
- color: #92400E;
-}
-
-.modTools .warningBox strong {
- display: block;
- margin-bottom: var(--mt-space-sm);
- color: var(--mt-warning);
- font-size: 15px;
-}
-
-.modTools .warningBox ul {
- margin: var(--mt-space-sm) 0 0;
- padding-left: var(--mt-space-lg);
-}
-
-.modTools .warningBox li {
- margin-bottom: var(--mt-space-sm);
-}
-
-.modTools .warningBox li:last-child {
- margin-bottom: 0;
-}
-
-.modTools .warningBox li strong {
- display: inline;
- margin-bottom: 0;
-}
-
-.modTools .warningBox li p {
- margin: var(--mt-space-xs) 0 0;
-}
-
-.modTools .dangerBox {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-error-bg);
- border: 1px solid var(--mt-error-border);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
- color: #991B1B;
-}
-
-.modTools .dangerBox strong {
- color: var(--mt-error);
-}
-
-/* Changes preview box */
-.modTools .changesPreview {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: linear-gradient(135deg, rgba(72, 113, 191, 0.08) 0%, rgba(24, 52, 93, 0.06) 100%);
- border: 1px solid rgba(72, 113, 191, 0.3);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
-}
-
-.modTools .changesPreview strong {
- display: block;
- margin-bottom: var(--mt-space-sm);
- color: var(--mt-primary);
-}
-
-.modTools .changesPreview ul {
- margin: var(--mt-space-xs) 0 0 var(--mt-space-lg);
- padding: 0;
-}
-
-.modTools .changesPreview li {
- margin-bottom: var(--mt-space-xs);
- color: var(--mt-text-secondary);
-}
-
-/* No results state */
-.modTools .noResults {
- padding: var(--mt-space-xl);
- text-align: center;
- color: var(--mt-text-muted);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-md);
- margin-top: var(--mt-space-md);
-}
-
-.modTools .noResults strong {
- display: block;
- color: var(--mt-text);
- margin-bottom: var(--mt-space-sm);
- font-size: 16px;
-}
-
-/* ==========================================================================
- Workflowy & Legacy Forms
- ========================================================================== */
-.modTools .workflowy-tool {
- width: 100%;
-}
-
-.modTools .workflowy-tool .workflowy-tool-form {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-md);
-}
-
-.modTools .workflowy-tool textarea {
- width: 100%;
- min-height: 200px;
- font-family: var(--mt-font-mono);
- font-size: 12px;
-}
-
-.modTools .getLinks,
-.modTools .uploadLinksFromCSV,
-.modTools .remove-links-csv {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-xs);
-}
-
-.modTools .getLinks form,
-.modTools .uploadLinksFromCSV form,
-.modTools .remove-links-csv form {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-xs);
-}
-
-.modTools .getLinks fieldset {
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-sm);
- padding: var(--mt-space-sm) var(--mt-space-md);
- margin: 0;
- background: var(--mt-bg-subtle);
-}
-
-.modTools .getLinks fieldset legend {
- padding: 0 var(--mt-space-xs);
- font-weight: 600;
- font-size: 12px;
-}
-
-/* Submit buttons in legacy forms */
-.modTools input[type="submit"] {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 6px 14px;
- background: var(--mt-primary);
- color: var(--mt-text-on-primary);
- font-family: var(--mt-font-body);
- font-size: 12px;
- font-weight: 600;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- transition: all var(--mt-transition);
- align-self: flex-start;
-}
-
-.modTools input[type="submit"]:hover {
- background: var(--mt-primary-hover);
-}
-
-.modTools input[type="submit"]:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-/* ==========================================================================
- Checkbox Styling
- ========================================================================== */
-.modTools input[type="checkbox"] {
- width: 14px;
- height: 14px;
- margin: 0;
- cursor: pointer;
- accent-color: var(--mt-primary);
-}
-
-.modTools .checkboxLabel {
- display: inline-flex;
- align-items: center;
- gap: var(--mt-space-xs);
- cursor: pointer;
- padding: 0;
- font-weight: 400;
- font-size: 13px;
-}
-
-.modTools label:has(input[type="checkbox"]) {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: var(--mt-space-xs);
- font-weight: 400;
- cursor: pointer;
- padding: var(--mt-space-xs) 0;
- font-size: 13px;
-}
-
-/* ==========================================================================
- 10. COLLAPSIBLE SECTIONS
- ========================================================================== */
-
-/* Section header - clickable to toggle */
-.modTools .sectionHeader {
- display: flex;
- align-items: center;
- justify-content: space-between;
- cursor: pointer;
- user-select: none;
- padding-bottom: var(--mt-space-md);
- margin-bottom: var(--mt-space-md);
- border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
- transition: all var(--mt-transition);
-}
-
-.modTools .sectionHeader:hover {
- border-bottom-color: var(--mt-primary);
-}
-
-.modTools .sectionHeader:hover .dlSectionTitle {
- color: var(--mt-primary-hover);
-}
-
-/* When section header exists, title shouldn't have its own border */
-.modTools .sectionHeader .dlSectionTitle {
- margin: 0;
- padding: 0; /* Reset legacy padding from s2.css */
- border-bottom: none;
- white-space: nowrap;
- line-height: 1;
-}
-
-/* Left side: collapse toggle + title */
-.modTools .sectionHeaderLeft {
- display: flex;
- align-items: center;
- gap: var(--mt-space-sm);
-}
-
-/* Right side: help button */
-.modTools .sectionHeaderRight {
- flex-shrink: 0;
-}
-
-/* Collapse toggle indicator - on the left */
-.modTools .collapseToggle {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: var(--mt-bg-subtle);
- border: 1.5px solid var(--mt-border);
- color: var(--mt-text-secondary);
- font-size: 14px;
- transition: all var(--mt-transition);
- flex-shrink: 0;
-}
-
-.modTools .sectionHeader:hover .collapseToggle {
- background: var(--mt-primary-light);
- border-color: var(--mt-primary);
- color: var(--mt-primary);
-}
-
-.modTools .collapseToggle img {
- width: 14px;
- height: 14px;
- transition: transform var(--mt-transition);
-}
-
-/* Collapsed state */
-.modTools .modToolsSection.collapsed .collapseToggle img {
- transform: rotate(-90deg);
-}
-
-.modTools .modToolsSection.collapsed .sectionHeader {
- margin-bottom: 0;
- padding-bottom: var(--mt-space-md);
-}
-
-/* Section content - animated collapse */
-.modTools .sectionContent {
- overflow: hidden;
- transition: max-height 0.3s ease-out, opacity 0.2s ease-out;
- max-height: 5000px; /* Large enough for any content */
- opacity: 1;
-}
-
-.modTools .modToolsSection.collapsed .sectionContent {
- max-height: 0;
- opacity: 0;
- pointer-events: none;
-}
-
-/* ==========================================================================
- 11. HELP BUTTON & MODAL
- ========================================================================== */
-
-/* Help Button - in section header actions */
-.modTools .helpButton {
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: var(--mt-bg-subtle);
- border: 1.5px solid var(--mt-border);
- color: var(--mt-text-secondary);
- font-size: 16px;
- font-weight: 600;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all var(--mt-transition);
- font-family: var(--mt-font-body);
- line-height: 1;
- flex-shrink: 0;
-}
-
-.modTools .helpButton:hover {
- background: var(--mt-primary);
- border-color: var(--mt-primary);
- color: var(--mt-text-on-primary);
- transform: scale(1.05);
-}
-
-.modTools .helpButton:focus {
- outline: none;
- box-shadow: 0 0 0 3px var(--mt-primary-light);
-}
-
-/* Modal Overlay */
-.modTools .helpModal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10000;
- padding: var(--mt-space-lg);
- animation: mt-fadeIn 0.15s ease-out;
-}
-
-@keyframes mt-fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-/* Modal Container */
-.modTools .helpModal {
- background: var(--mt-bg-card);
- border-radius: var(--mt-radius-lg);
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
- max-width: 680px;
- width: 100%;
- max-height: 85vh;
- display: flex;
- flex-direction: column;
- animation: mt-slideUp 0.2s ease-out;
-}
-
-@keyframes mt-slideUp {
- from {
- opacity: 0;
- transform: translateY(20px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* Modal Header */
-.modTools .helpModal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--mt-space-lg) var(--mt-space-xl);
- border-bottom: 1px solid var(--mt-border);
- flex-shrink: 0;
-}
-
-.modTools .helpModal-title {
- font-family: var(--mt-font-display);
- font-size: var(--mt-text-2xl);
- font-weight: var(--mt-font-semibold);
- color: var(--mt-primary);
- margin: 0;
- line-height: 1.3;
-}
-
-.modTools .helpModal-close {
- width: 36px;
- height: 36px;
- border-radius: 50%;
- border: none;
- background: var(--mt-bg-subtle);
- color: var(--mt-text-secondary);
- font-size: 24px;
- line-height: 1;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all var(--mt-transition);
-}
-
-.modTools .helpModal-close:hover {
- background: var(--mt-error-bg);
- color: var(--mt-error);
-}
-
-/* Modal Body - Scrollable */
-.modTools .helpModal-body {
- padding: var(--mt-space-xl);
- overflow-y: auto;
- flex: 1;
- font-size: 15px;
- line-height: 1.7;
- color: var(--mt-text);
-}
-
-/* Content styling within modal */
-.modTools .helpModal-body h3 {
- font-family: var(--mt-font-body);
- font-size: 16px;
- font-weight: 600;
- color: var(--mt-primary);
- margin: var(--mt-space-lg) 0 var(--mt-space-md) 0;
- padding-bottom: var(--mt-space-sm);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .helpModal-body h3:first-child {
- margin-top: 0;
-}
-
-.modTools .helpModal-body p {
- margin: 0 0 var(--mt-space-md) 0;
-}
-
-.modTools .helpModal-body ul,
-.modTools .helpModal-body ol {
- margin: 0 0 var(--mt-space-md) 0;
- padding-left: var(--mt-space-xl);
-}
-
-.modTools .helpModal-body li {
- margin-bottom: var(--mt-space-sm);
-}
-
-.modTools .helpModal-body li:last-child {
- margin-bottom: 0;
-}
-
-.modTools .helpModal-body strong {
- font-weight: 600;
- color: var(--mt-text);
-}
-
-.modTools .helpModal-body code {
- font-family: var(--mt-font-mono);
- font-size: 13px;
- background: var(--mt-bg-subtle);
- padding: 2px 6px;
- border-radius: 4px;
- color: var(--mt-primary);
-}
-
-.modTools .helpModal-body .warning {
- background: var(--mt-warning-bg);
- border: 1px solid var(--mt-warning-border);
- border-radius: var(--mt-radius-md);
- padding: var(--mt-space-md);
- margin: var(--mt-space-md) 0;
- color: var(--mt-warning-text);
-}
-
-.modTools .helpModal-body .warning strong {
- color: var(--mt-warning);
-}
-
-.modTools .helpModal-body .info {
- background: var(--mt-info-bg);
- border: 1px solid var(--mt-info-border);
- border-radius: var(--mt-radius-md);
- padding: var(--mt-space-md);
- margin: var(--mt-space-md) 0;
- color: var(--mt-info-text);
-}
-
-.modTools .helpModal-body .field-table {
- width: 100%;
- border-collapse: collapse;
- margin: var(--mt-space-md) 0;
- font-size: 14px;
-}
-
-.modTools .helpModal-body .field-table th,
-.modTools .helpModal-body .field-table td {
- text-align: left;
- padding: var(--mt-space-sm) var(--mt-space-md);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .helpModal-body .field-table th {
- background: var(--mt-bg-subtle);
- font-weight: 600;
- color: var(--mt-text);
-}
-
-.modTools .helpModal-body .field-table tr:last-child td {
- border-bottom: none;
-}
-
-/* Modal Footer */
-.modTools .helpModal-footer {
- padding: var(--mt-space-md) var(--mt-space-xl);
- border-top: 1px solid var(--mt-border);
- display: flex;
- justify-content: flex-end;
- flex-shrink: 0;
-}
-
-/* ==========================================================================
- Print Styles
- ========================================================================== */
-@media print {
- .modTools {
- background: white;
- }
-
- .modTools::before {
- display: none;
- }
-
- .modTools .modToolsSection {
- box-shadow: none;
- border: 1px solid #ddd;
- break-inside: avoid;
- }
-
- .modTools .modToolsSection.collapsed .sectionContent {
- max-height: none;
- opacity: 1;
- }
-
- .modTools .modtoolsButton {
- display: none;
- }
-
- .modTools .helpButton,
- .modTools .collapseToggle {
- display: none;
- }
-
- .modTools .helpModal-overlay {
- display: none;
- }
-}
diff --git a/static/css/static.css b/static/css/static.css
index 9328fc2387..b91322b574 100644
--- a/static/css/static.css
+++ b/static/css/static.css
@@ -3688,3 +3688,1655 @@ form.globalUpdateForm + div.notificationsList {
.updateTextarea {
width: 100%;
}
+/**
+ * ModTools Design System
+ * ======================
+ * A refined admin dashboard with scholarly character.
+ *
+ * STRUCTURE:
+ * 1. Design Tokens (CSS Variables)
+ * 2. Base & Reset
+ * 3. Layout Components (Section, Cards)
+ * 4. Form Elements (Inputs, Selects, Buttons)
+ * 5. Layout Patterns (SearchRow, FilterRow, ActionRow)
+ * 6. Data Display (IndexSelector, NodeList)
+ * 7. Feedback (Alerts, Messages, Status)
+ * 8. Utilities
+ * 9. Responsive
+ *
+ * NAMING CONVENTION:
+ * - All classes prefixed with context (e.g., .modTools .searchRow)
+ * - Variables prefixed with --mt- (mod tools)
+ * - BEM-lite: .component, .component-element, .component.modifier
+ */
+
+/* Google Fonts - Scholarly + Modern pairing */
+@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
+
+/* ==========================================================================
+ 1. DESIGN TOKENS
+ ========================================================================== */
+:root {
+ /*
+ * COLOR PALETTE
+ * Using Sefaria design system colors from s2.css
+ */
+
+ /* Background colors */
+ --mt-bg-page: var(--lightest-grey); /* Main page background */
+ --mt-bg-card: #FFFFFF; /* Card/section background */
+ --mt-bg-subtle: var(--lighter-grey); /* Subtle background for groupings */
+ --mt-bg-input: var(--lightest-grey); /* Input field background */
+ --mt-bg-hover: var(--lighter-grey); /* Hover state background */
+
+ /* Brand colors */
+ --mt-primary: var(--sefaria-blue); /* Primary actions, headings */
+ --mt-primary-hover: #122B4A; /* Primary hover state (darker sefaria-blue) */
+ --mt-primary-light: rgba(24, 52, 93, 0.08); /* Primary tint for backgrounds */
+ --mt-accent: var(--inline-link-blue); /* Accent/links */
+ --mt-accent-hover: #3A5FA6; /* Accent hover */
+ --mt-accent-light: rgba(72, 113, 191, 0.1); /* Accent tint */
+
+ /* Text colors */
+ --mt-text: var(--darkest-grey); /* Primary text */
+ --mt-text-secondary: var(--dark-grey); /* Secondary/supporting text */
+ --mt-text-muted: var(--medium-grey); /* Muted/placeholder text */
+ --mt-text-on-primary: #FFFFFF; /* Text on primary color */
+
+ /* Border colors */
+ --mt-border: var(--lighter-grey); /* Default border */
+ --mt-border-hover: var(--light-grey); /* Border on hover */
+ --mt-border-focus: var(--mt-primary); /* Border on focus */
+
+ /* Status colors - Success */
+ --mt-success: #059669;
+ --mt-success-bg: #ECFDF5;
+ --mt-success-border: #A7F3D0;
+ --mt-success-text: #065F46;
+
+ /* Status colors - Warning */
+ --mt-warning: #D97706;
+ --mt-warning-bg: #FFFBEB;
+ --mt-warning-border: #FDE68A;
+ --mt-warning-text: #92400E;
+
+ /* Status colors - Error */
+ --mt-error: #DC2626;
+ --mt-error-bg: #FEF2F2;
+ --mt-error-border: #FECACA;
+ --mt-error-text: #991B1B;
+
+ /* Status colors - Info */
+ --mt-info: #0891B2;
+ --mt-info-bg: #ECFEFF;
+ --mt-info-border: #A5F3FC;
+ --mt-info-text: #0E7490;
+
+ /*
+ * TYPOGRAPHY
+ */
+
+ /* Font families */
+ --mt-font-display: "Crimson Pro", "Georgia", serif;
+ --mt-font-body: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif;
+ --mt-font-hebrew: "Heebo", "Arial Hebrew", sans-serif;
+ --mt-font-mono: "JetBrains Mono", "Fira Code", monospace;
+
+ /* Font sizes - Using a modular scale */
+ --mt-text-xs: 11px; /* Small labels, badges */
+ --mt-text-sm: 13px; /* Help text, meta */
+ --mt-text-base: 15px; /* Body text */
+ --mt-text-md: 16px; /* Slightly larger body */
+ --mt-text-lg: 18px; /* Section intros */
+ --mt-text-xl: 20px; /* Mobile headings */
+ --mt-text-2xl: 24px; /* Section titles */
+
+ /* Font weights */
+ --mt-font-normal: 400;
+ --mt-font-medium: 500;
+ --mt-font-semibold: 600;
+ --mt-font-bold: 700;
+
+ /* Line heights */
+ --mt-leading-tight: 1.3;
+ --mt-leading-normal: 1.5;
+ --mt-leading-relaxed: 1.6;
+
+ /*
+ * SPACING
+ * Based on 4px grid - Compact design
+ */
+ --mt-space-xs: 2px; /* Tight spacing */
+ --mt-space-sm: 4px; /* Small gaps */
+ --mt-space-md: 8px; /* Medium gaps, default padding */
+ --mt-space-lg: 12px; /* Large gaps, section spacing */
+ --mt-space-xl: 16px; /* Extra large, card padding */
+ --mt-space-2xl: 24px; /* Major section breaks */
+
+ /*
+ * BORDERS & EFFECTS
+ */
+
+ /* Border radius */
+ --mt-radius-sm: 6px; /* Small elements, badges */
+ --mt-radius-md: 10px; /* Inputs, buttons */
+ --mt-radius-lg: 14px; /* Cards, sections */
+
+ /* Border width */
+ --mt-border-width: 1px;
+ --mt-border-width-thick: 1.5px;
+ --mt-border-width-heavy: 2px;
+
+ /* Shadows */
+ --mt-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
+ --mt-shadow-card: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
+ --mt-shadow-elevated: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
+ --mt-shadow-focus: 0 0 0 4px var(--mt-primary-light);
+
+ /*
+ * ANIMATION
+ */
+ --mt-transition-fast: 100ms cubic-bezier(0.4, 0, 0.2, 1);
+ --mt-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --mt-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ /*
+ * LAYOUT
+ */
+ --mt-max-width: 1100px;
+ --mt-input-height: 36px; /* Standard input height - compact */
+}
+
+/* ==========================================================================
+ 2. BASE & RESET
+ ========================================================================== */
+.modTools {
+ width: 100%;
+ min-height: 100vh;
+ padding: var(--mt-space-lg) var(--mt-space-md);
+ padding-bottom: 40px; /* Bottom margin */
+ background: var(--mt-bg-page);
+ font-family: var(--mt-font-body);
+ font-size: var(--mt-text-sm);
+ line-height: var(--mt-leading-normal);
+ color: var(--mt-text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ box-sizing: border-box;
+}
+
+/*
+ * Inner container with max-width.
+ * The outer .modTools is full-width so users can scroll from the side margins
+ * without inner scrollable elements (like .indexList) capturing the scroll.
+ */
+.modTools .modToolsInner {
+ max-width: var(--mt-max-width);
+ margin: 0 auto;
+}
+
+.modTools *,
+.modTools *::before,
+.modTools *::after {
+ box-sizing: border-box;
+}
+
+/* Page header */
+.modTools .modToolsInner::before {
+ content: "Moderator Tools";
+ display: block;
+ font-family: var(--mt-font-display);
+ font-size: 22px;
+ font-weight: var(--mt-font-semibold);
+ color: var(--mt-primary);
+ padding: var(--mt-space-md) 0;
+ margin-bottom: var(--mt-space-lg);
+ border-bottom: 2px solid var(--mt-border);
+ letter-spacing: -0.01em;
+}
+
+/* ==========================================================================
+ 3. LAYOUT COMPONENTS
+ ========================================================================== */
+
+/* --- Section Cards --- */
+.modTools .modToolsSection {
+ background: var(--mt-bg-card);
+ border-radius: var(--mt-radius-sm);
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-sm);
+ box-shadow: var(--mt-shadow-sm);
+ border: 1px solid var(--mt-border);
+ transition: box-shadow var(--mt-transition);
+ overflow: visible;
+}
+
+.modTools .modToolsSection:hover {
+ box-shadow: var(--mt-shadow-elevated);
+}
+
+/* Section Title */
+.modTools .dlSectionTitle {
+ font-family: var(--mt-font-display);
+ font-size: var(--mt-text-lg);
+ font-weight: var(--mt-font-semibold);
+ color: var(--mt-primary);
+ margin: 0 0 var(--mt-space-xs) 0;
+ padding-bottom: var(--mt-space-sm);
+ border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
+ letter-spacing: -0.01em;
+ line-height: var(--mt-leading-tight);
+}
+
+.modTools .dlSectionTitle .int-he {
+ font-family: var(--mt-font-hebrew);
+ margin-left: var(--mt-space-md);
+ font-size: var(--mt-text-lg);
+ color: var(--mt-text-secondary);
+}
+
+/* Section subtitle/description */
+.modTools .sectionDescription {
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+ margin-bottom: var(--mt-space-md);
+ line-height: var(--mt-leading-normal);
+}
+
+/* ==========================================================================
+ 4. FORM ELEMENTS
+ ========================================================================== */
+
+/* --- Labels --- */
+.modTools label {
+ display: block;
+ font-size: 13px;
+ font-weight: var(--mt-font-medium);
+ color: var(--mt-text);
+ margin-bottom: var(--mt-space-xs);
+}
+
+/* --- Input Base Styles --- */
+.modTools .dlVersionSelect,
+.modTools input[type="text"],
+.modTools input[type="number"],
+.modTools input[type="url"],
+.modTools select,
+.modTools textarea {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ padding: 6px 10px;
+ margin-bottom: var(--mt-space-sm);
+ font-family: var(--mt-font-body);
+ font-size: var(--mt-text-sm);
+ line-height: var(--mt-leading-normal);
+ color: var(--mt-text);
+ background: var(--mt-bg-input);
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-sm);
+ transition: all var(--mt-transition);
+ box-sizing: border-box;
+}
+
+.modTools .dlVersionSelect::placeholder,
+.modTools input::placeholder,
+.modTools textarea::placeholder {
+ color: var(--mt-text-muted);
+}
+
+.modTools .dlVersionSelect:hover,
+.modTools input[type="text"]:hover,
+.modTools input[type="number"]:hover,
+.modTools input[type="url"]:hover,
+.modTools select:hover,
+.modTools textarea:hover {
+ border-color: var(--mt-border-hover);
+ background: var(--mt-bg-card);
+}
+
+.modTools .dlVersionSelect:focus,
+.modTools input[type="text"]:focus,
+.modTools input[type="number"]:focus,
+.modTools input[type="url"]:focus,
+.modTools select:focus,
+.modTools textarea:focus {
+ outline: none;
+ border-color: var(--mt-border-focus);
+ background: var(--mt-bg-card);
+ box-shadow: var(--mt-shadow-focus);
+}
+
+/* Select dropdowns - Clear arrow indicator */
+.modTools select,
+.modTools select.dlVersionSelect {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background-color: var(--mt-bg-input);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2318345D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 14px center;
+ background-size: 14px 14px;
+ padding-right: 44px !important;
+ cursor: pointer;
+}
+
+/* --- Textarea --- */
+.modTools textarea {
+ min-height: 100px;
+ resize: vertical;
+ font-family: var(--mt-font-body);
+ font-size: 14px;
+ line-height: var(--mt-leading-relaxed);
+}
+
+/* ==========================================================================
+ 5. LAYOUT PATTERNS
+ ========================================================================== */
+
+/* --- Input Row (legacy, stacked) --- */
+.modTools .inputRow {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-md);
+ margin-bottom: var(--mt-space-lg);
+}
+
+.modTools .inputRow input,
+.modTools .inputRow select {
+ margin-bottom: 0;
+}
+
+/* Search row - Input + Button inline */
+.modTools .searchRow {
+ display: flex;
+ gap: var(--mt-space-md);
+ align-items: flex-start;
+ margin-bottom: var(--mt-space-lg);
+}
+
+.modTools .searchRow input {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.modTools .searchRow .modtoolsButton {
+ flex-shrink: 0;
+ white-space: nowrap;
+ /* Standard button sizing - not stretched */
+ padding: 6px 14px;
+ min-width: auto;
+ width: auto;
+}
+
+/* Filter row - label inline with dropdown */
+.modTools .filterRow {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+ margin-bottom: var(--mt-space-lg);
+}
+
+.modTools .filterRow label {
+ margin-bottom: 0;
+ white-space: nowrap;
+}
+
+.modTools .filterRow select {
+ margin-bottom: 0;
+ max-width: 200px;
+}
+
+/* Clear search button - centered */
+.modTools .clearSearchRow {
+ display: flex;
+ justify-content: center;
+ margin-bottom: var(--mt-space-lg);
+}
+
+/* Action button row - for primary action buttons */
+.modTools .actionRow {
+ display: flex;
+ gap: var(--mt-space-md);
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: var(--mt-space-lg);
+}
+
+/* Separator before delete section */
+.modTools .deleteSectionSeparator {
+ margin-top: var(--mt-space-xl);
+ margin-bottom: var(--mt-space-md);
+ border-top: 1px solid var(--mt-border);
+}
+
+/* Section intro text - for counts, descriptions */
+.modTools .sectionIntro {
+ margin-bottom: var(--mt-space-md);
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--mt-text);
+}
+
+/* Subsection heading */
+.modTools .subsectionHeading {
+ margin-top: var(--mt-space-lg);
+ margin-bottom: var(--mt-space-md);
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--mt-text);
+}
+
+/* Option row - for single option with label */
+.modTools .optionRow {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+ margin-bottom: var(--mt-space-md);
+}
+
+.modTools .optionRow label {
+ margin-bottom: 0;
+ white-space: nowrap;
+ min-width: fit-content;
+}
+
+.modTools .optionRow select,
+.modTools .optionRow input {
+ margin-bottom: 0;
+ flex: 1;
+ max-width: 300px;
+}
+
+/* Node list container - for scrollable lists */
+.modTools .nodeListContainer {
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-md);
+ padding: var(--mt-space-md);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-bg-card);
+}
+
+/* Node item - for individual editable items */
+.modTools .nodeItem {
+ margin-bottom: var(--mt-space-md);
+ padding: var(--mt-space-md);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-sm);
+ border: 1px solid var(--mt-border);
+ transition: all var(--mt-transition);
+}
+
+.modTools .nodeItem:last-child {
+ margin-bottom: 0;
+}
+
+.modTools .nodeItem.modified {
+ background: var(--mt-warning-bg);
+ border-color: var(--mt-warning-border);
+}
+
+.modTools .nodeItem .nodeGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--mt-space-md);
+}
+
+.modTools .nodeItem .nodeMeta {
+ margin-top: var(--mt-space-sm);
+ font-size: 12px;
+ color: var(--mt-text-muted);
+}
+
+.modTools .nodeItem .nodeSharedTitle {
+ margin-bottom: var(--mt-space-sm);
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+}
+
+/* Small label for form fields */
+.modTools .fieldLabel {
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+ margin-bottom: var(--mt-space-xs);
+ font-weight: 500;
+}
+
+/* Validation error on input */
+.modTools input.hasError,
+.modTools select.hasError {
+ border-color: var(--mt-error) !important;
+ background: var(--mt-error-bg);
+}
+
+.modTools .validationHint {
+ font-size: var(--mt-text-sm);
+ color: var(--mt-error);
+ margin-top: var(--mt-space-xs);
+}
+
+/* --- Two-column grid for related fields --- */
+.modTools .formGrid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--mt-space-md);
+}
+
+.modTools .formGrid.fullWidth {
+ grid-column: 1 / -1;
+}
+
+/* ==========================================================================
+ 6. BUTTONS
+ ========================================================================== */
+
+/* --- Primary Button --- */
+.modTools .modtoolsButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--mt-space-xs);
+ padding: 6px 14px;
+ background: var(--mt-primary);
+ color: var(--mt-text-on-primary);
+ font-family: var(--mt-font-body);
+ font-size: 12px;
+ font-weight: 600;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all var(--mt-transition);
+ text-decoration: none;
+ white-space: nowrap;
+ text-align: center;
+}
+
+.modTools .modtoolsButton:hover {
+ background: var(--mt-primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--mt-shadow-sm);
+}
+
+.modTools .modtoolsButton:active {
+ transform: translateY(0);
+}
+
+.modTools .modtoolsButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* Secondary button */
+.modTools .modtoolsButton.secondary {
+ background: transparent;
+ color: var(--mt-primary);
+ border: 2px solid var(--mt-primary);
+ padding: 6px 14px;
+}
+
+.modTools .modtoolsButton.secondary:hover {
+ background: var(--mt-primary-light);
+}
+
+/* Danger button */
+.modTools .modtoolsButton.danger {
+ background: var(--mt-error);
+}
+
+.modTools .modtoolsButton.danger:hover {
+ background: #B91C1C;
+}
+
+/* Small button */
+.modTools .modtoolsButton.small {
+ padding: 5px 10px;
+ font-size: 12px;
+}
+
+/* Loading spinner in buttons */
+.modTools .loadingSpinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: mt-spin 0.7s linear infinite;
+}
+
+@keyframes mt-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Button row */
+.modTools .buttonRow {
+ display: flex;
+ gap: var(--mt-space-md);
+ flex-wrap: wrap;
+ margin-top: var(--mt-space-lg);
+}
+
+/* --- File Upload Zones --- */
+.modTools input[type="file"] {
+ display: block;
+ width: 100%;
+ padding: var(--mt-space-md);
+ margin-bottom: var(--mt-space-sm);
+ background: var(--mt-bg-subtle);
+ border: 2px dashed var(--mt-border);
+ border-radius: var(--mt-radius-sm);
+ cursor: pointer;
+ font-family: var(--mt-font-body);
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+}
+
+.modTools input[type="file"]:hover {
+ border-color: var(--mt-primary);
+ background: var(--mt-primary-light);
+}
+
+.modTools input[type="file"]::file-selector-button {
+ padding: 6px 12px;
+ margin-right: var(--mt-space-sm);
+ background: var(--mt-primary);
+ color: white;
+ border: none;
+ border-radius: var(--mt-radius-sm);
+ font-family: var(--mt-font-body);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background var(--mt-transition);
+}
+
+.modTools input[type="file"]::file-selector-button:hover {
+ background: var(--mt-primary-hover);
+}
+
+/* ==========================================================================
+ 7. DATA DISPLAY COMPONENTS
+ ========================================================================== */
+
+/* --- Index Selector --- */
+.modTools .indexSelectorContainer {
+ margin-top: var(--mt-space-lg);
+ background: var(--mt-bg-card);
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-lg);
+ overflow: hidden;
+}
+
+/* Header */
+.modTools .indexSelectorHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ background: var(--mt-bg-subtle);
+ border-bottom: 1px solid var(--mt-border);
+ flex-wrap: wrap;
+ gap: var(--mt-space-md);
+}
+
+.modTools .indexSelectorTitle {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--mt-text);
+}
+
+.modTools .indexSelectorTitle .highlight {
+ color: var(--mt-primary);
+ font-weight: 700;
+}
+
+.modTools .indexSelectorActions {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-lg);
+}
+
+.modTools .selectionCount {
+ font-size: 14px;
+ color: var(--mt-text-secondary);
+}
+
+.modTools .selectAllToggle {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-sm);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--mt-text);
+ cursor: pointer;
+ margin-bottom: 0;
+}
+
+.modTools .selectAllToggle input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--mt-primary);
+ cursor: pointer;
+}
+
+/* Search input */
+.modTools .indexSearchWrapper {
+ position: relative;
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ background: var(--mt-bg-card);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .indexSearchInput {
+ width: 100%;
+ padding: 10px 40px 10px 16px;
+ margin: 0;
+ font-size: 14px;
+ border: 1.5px solid var(--mt-border);
+ border-radius: var(--mt-radius-md);
+ background: var(--mt-bg-input);
+}
+
+.modTools .indexSearchInput:focus {
+ outline: none;
+ border-color: var(--mt-primary);
+ box-shadow: 0 0 0 3px var(--mt-primary-light);
+}
+
+.modTools .indexSearchClear {
+ position: absolute;
+ right: calc(var(--mt-space-lg) + 12px);
+ top: 50%;
+ transform: translateY(-50%);
+ width: 24px;
+ height: 24px;
+ border: none;
+ background: transparent;
+ color: var(--mt-text-muted);
+ font-size: 18px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+}
+
+.modTools .indexSearchClear:hover {
+ background: var(--mt-bg-subtle);
+ color: var(--mt-text);
+}
+
+/* Index List */
+.modTools .indexList {
+ display: flex;
+ flex-direction: column;
+ max-height: 400px;
+ overflow-y: auto;
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-md);
+ background: var(--mt-bg-card);
+}
+
+/* Custom scrollbar for index list */
+.modTools .indexList::-webkit-scrollbar {
+ width: 8px;
+}
+
+.modTools .indexList::-webkit-scrollbar-track {
+ background: var(--mt-bg-subtle);
+ border-radius: 4px;
+}
+
+.modTools .indexList::-webkit-scrollbar-thumb {
+ background: var(--mt-border);
+ border-radius: 4px;
+}
+
+.modTools .indexList::-webkit-scrollbar-thumb:hover {
+ background: #B5B3AC;
+}
+
+/* Index List Row */
+.modTools .indexListRow {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ border-bottom: 1px solid var(--mt-border);
+ cursor: pointer;
+ transition: background var(--mt-transition);
+}
+
+.modTools .indexListRow:last-child {
+ border-bottom: none;
+}
+
+.modTools .indexListRow:hover {
+ background: var(--mt-primary-light);
+}
+
+.modTools .indexListRow.selected {
+ background: rgba(24, 52, 93, 0.08);
+ box-shadow: inset 3px 0 0 var(--mt-primary);
+}
+
+.modTools .indexListRow input[type="checkbox"] {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ accent-color: var(--mt-primary);
+ cursor: pointer;
+}
+
+.modTools .indexListTitle {
+ flex: 1;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--mt-text);
+ line-height: 1.4;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.modTools .indexListCategory {
+ flex-shrink: 0;
+ font-size: 12px;
+ color: var(--mt-text-muted);
+ padding: 2px 8px;
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-sm);
+}
+
+/* No results in search */
+.modTools .indexNoResults {
+ text-align: center;
+ padding: var(--mt-space-xl);
+ color: var(--mt-text-muted);
+ font-size: 14px;
+}
+
+/* Legacy indices list (fallback) */
+.modTools .selectionControls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--mt-space-md);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-md) var(--mt-radius-md) 0 0;
+ border: 1px solid var(--mt-border);
+ border-bottom: none;
+}
+
+.modTools .selectionButtons {
+ display: flex;
+ gap: var(--mt-space-sm);
+}
+
+.modTools .indicesList {
+ max-height: 280px;
+ overflow-y: auto;
+ border: 1px solid var(--mt-border);
+ border-radius: 0 0 var(--mt-radius-md) var(--mt-radius-md);
+ padding: var(--mt-space-sm);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-bg-card);
+ scroll-behavior: smooth;
+}
+
+.modTools .indicesList label {
+ display: flex;
+ align-items: center;
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ margin: 2px 0;
+ cursor: pointer;
+ border-radius: var(--mt-radius-sm);
+ transition: background var(--mt-transition);
+ font-weight: 400;
+ font-size: 14px;
+}
+
+.modTools .indicesList label:hover {
+ background: var(--mt-bg-subtle);
+}
+
+.modTools .indicesList label.selected {
+ background: var(--mt-primary-light);
+ border-left: 3px solid var(--mt-primary);
+}
+
+.modTools .indicesList label input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ margin: 0 var(--mt-space-md) 0 0;
+}
+
+/* ==========================================================================
+ 8. FIELD GROUPS
+ ========================================================================== */
+.modTools .fieldGroup {
+ margin-bottom: var(--mt-space-sm);
+}
+
+.modTools .fieldGroup label {
+ margin-bottom: var(--mt-space-xs);
+}
+
+.modTools .fieldGroup .fieldInput {
+ margin-bottom: var(--mt-space-xs);
+}
+
+.modTools .fieldGroup .fieldInput:disabled {
+ background-color: #f5f5f5;
+ color: #999;
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.modTools .fieldHelp {
+ font-size: 13px;
+ color: var(--mt-text-muted);
+ margin-bottom: var(--mt-space-sm);
+ line-height: 1.5;
+}
+
+/* Field group sections */
+.modTools .fieldGroupSection {
+ margin-bottom: var(--mt-space-md);
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-sm);
+ border: 1px solid var(--mt-border);
+}
+
+.modTools .fieldGroupHeader {
+ font-family: var(--mt-font-body);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--mt-text-muted);
+ margin-bottom: var(--mt-space-md);
+ padding-bottom: var(--mt-space-sm);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .fieldGroupGrid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--mt-space-lg) var(--mt-space-md);
+ align-items: end; /* Align inputs at bottom of each row */
+}
+
+.modTools .fieldGroupGrid .fieldGroup {
+ margin-bottom: 0;
+}
+
+.modTools .fieldGroup.fullWidth {
+ grid-column: 1 / -1;
+}
+
+/* Field name badge */
+.modTools .fieldNameBadge {
+ display: inline-block;
+ font-family: var(--mt-font-mono);
+ font-size: 11px;
+ padding: 2px 6px;
+ background: var(--mt-bg-subtle);
+ border: 1px solid var(--mt-border);
+ border-radius: 4px;
+ color: var(--mt-text-muted);
+ margin-left: var(--mt-space-sm);
+ vertical-align: middle;
+}
+
+/* Validation states */
+.modTools .fieldGroup.hasError input,
+.modTools .fieldGroup.hasError select {
+ border-color: var(--mt-error);
+ background: var(--mt-error-bg);
+}
+
+.modTools .fieldError {
+ font-size: var(--mt-text-sm);
+ color: var(--mt-error);
+ margin-top: var(--mt-space-xs);
+}
+
+.modTools .requiredIndicator {
+ color: var(--mt-error);
+ font-weight: bold;
+ margin-left: 2px;
+}
+
+/* ==========================================================================
+ 9. FEEDBACK & ALERTS
+ ========================================================================== */
+.modTools .message {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-top: var(--mt-space-md);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.modTools .message.success {
+ background: var(--mt-success-bg);
+ color: var(--mt-success);
+ border: 1px solid var(--mt-success-border);
+}
+
+.modTools .message.warning {
+ background: var(--mt-warning-bg);
+ color: var(--mt-warning-text);
+ border: 1px solid var(--mt-warning-border);
+}
+
+.modTools .message.error {
+ background: var(--mt-error-bg);
+ color: var(--mt-error);
+ border: 1px solid var(--mt-error-border);
+}
+
+.modTools .message.info {
+ background: var(--mt-info-bg);
+ color: #0E7490;
+ border: 1px solid var(--mt-info-border);
+}
+
+/* Info/Warning/Danger boxes */
+.modTools .infoBox {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-info-bg);
+ border: 1px solid var(--mt-info-border);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+ color: #0E7490;
+}
+
+.modTools .infoBox strong {
+ color: var(--mt-info);
+}
+
+.modTools .warningBox {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-warning-bg);
+ border: 1px solid var(--mt-warning-border);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+ color: #92400E;
+}
+
+.modTools .warningBox strong {
+ display: block;
+ margin-bottom: var(--mt-space-sm);
+ color: var(--mt-warning);
+ font-size: 15px;
+}
+
+.modTools .warningBox ul {
+ margin: var(--mt-space-sm) 0 0;
+ padding-left: var(--mt-space-lg);
+}
+
+.modTools .warningBox li {
+ margin-bottom: var(--mt-space-sm);
+}
+
+.modTools .warningBox li:last-child {
+ margin-bottom: 0;
+}
+
+.modTools .warningBox li strong {
+ display: inline;
+ margin-bottom: 0;
+}
+
+.modTools .warningBox li p {
+ margin: var(--mt-space-xs) 0 0;
+}
+
+.modTools .dangerBox {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-error-bg);
+ border: 1px solid var(--mt-error-border);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+ color: #991B1B;
+}
+
+.modTools .dangerBox strong {
+ color: var(--mt-error);
+}
+
+/* Changes preview box */
+.modTools .changesPreview {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: linear-gradient(135deg, rgba(72, 113, 191, 0.08) 0%, rgba(24, 52, 93, 0.06) 100%);
+ border: 1px solid rgba(72, 113, 191, 0.3);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+}
+
+.modTools .changesPreview strong {
+ display: block;
+ margin-bottom: var(--mt-space-sm);
+ color: var(--mt-primary);
+}
+
+.modTools .changesPreview ul {
+ margin: var(--mt-space-xs) 0 0 var(--mt-space-lg);
+ padding: 0;
+}
+
+.modTools .changesPreview li {
+ margin-bottom: var(--mt-space-xs);
+ color: var(--mt-text-secondary);
+}
+
+/* No results state */
+.modTools .noResults {
+ padding: var(--mt-space-xl);
+ text-align: center;
+ color: var(--mt-text-muted);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-md);
+ margin-top: var(--mt-space-md);
+}
+
+.modTools .noResults strong {
+ display: block;
+ color: var(--mt-text);
+ margin-bottom: var(--mt-space-sm);
+ font-size: 16px;
+}
+
+/* ==========================================================================
+ Workflowy & Legacy Forms
+ ========================================================================== */
+.modTools .workflowy-tool {
+ width: 100%;
+}
+
+.modTools .workflowy-tool .workflowy-tool-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-md);
+}
+
+.modTools .workflowy-tool textarea {
+ width: 100%;
+ min-height: 200px;
+ font-family: var(--mt-font-mono);
+ font-size: 12px;
+}
+
+.modTools .getLinks,
+.modTools .uploadLinksFromCSV,
+.modTools .remove-links-csv {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-xs);
+}
+
+.modTools .getLinks form,
+.modTools .uploadLinksFromCSV form,
+.modTools .remove-links-csv form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-xs);
+}
+
+.modTools .getLinks fieldset {
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-sm);
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ margin: 0;
+ background: var(--mt-bg-subtle);
+}
+
+.modTools .getLinks fieldset legend {
+ padding: 0 var(--mt-space-xs);
+ font-weight: 600;
+ font-size: 12px;
+}
+
+/* Submit buttons in legacy forms */
+.modTools input[type="submit"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 14px;
+ background: var(--mt-primary);
+ color: var(--mt-text-on-primary);
+ font-family: var(--mt-font-body);
+ font-size: 12px;
+ font-weight: 600;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all var(--mt-transition);
+ align-self: flex-start;
+}
+
+.modTools input[type="submit"]:hover {
+ background: var(--mt-primary-hover);
+}
+
+.modTools input[type="submit"]:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ==========================================================================
+ Checkbox Styling
+ ========================================================================== */
+.modTools input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ cursor: pointer;
+ accent-color: var(--mt-primary);
+}
+
+.modTools .checkboxLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--mt-space-xs);
+ cursor: pointer;
+ padding: 0;
+ font-weight: 400;
+ font-size: 13px;
+}
+
+.modTools label:has(input[type="checkbox"]) {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--mt-space-xs);
+ font-weight: 400;
+ cursor: pointer;
+ padding: var(--mt-space-xs) 0;
+ font-size: 13px;
+}
+
+/* ==========================================================================
+ 10. COLLAPSIBLE SECTIONS
+ ========================================================================== */
+
+/* Section header - clickable to toggle */
+.modTools .sectionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ user-select: none;
+ padding-bottom: var(--mt-space-md);
+ margin-bottom: var(--mt-space-md);
+ border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
+ transition: all var(--mt-transition);
+}
+
+.modTools .sectionHeader:hover {
+ border-bottom-color: var(--mt-primary);
+}
+
+.modTools .sectionHeader:hover .dlSectionTitle {
+ color: var(--mt-primary-hover);
+}
+
+/* When section header exists, title shouldn't have its own border */
+.modTools .sectionHeader .dlSectionTitle {
+ margin: 0;
+ padding: 0; /* Reset legacy padding from s2.css */
+ border-bottom: none;
+ white-space: nowrap;
+ line-height: 1;
+}
+
+/* Left side: collapse toggle + title */
+.modTools .sectionHeaderLeft {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-sm);
+}
+
+/* Right side: help button */
+.modTools .sectionHeaderRight {
+ flex-shrink: 0;
+}
+
+/* Collapse toggle indicator - on the left */
+.modTools .collapseToggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--mt-bg-subtle);
+ border: 1.5px solid var(--mt-border);
+ color: var(--mt-text-secondary);
+ font-size: 14px;
+ transition: all var(--mt-transition);
+ flex-shrink: 0;
+}
+
+.modTools .sectionHeader:hover .collapseToggle {
+ background: var(--mt-primary-light);
+ border-color: var(--mt-primary);
+ color: var(--mt-primary);
+}
+
+.modTools .collapseToggle img {
+ width: 14px;
+ height: 14px;
+ transition: transform var(--mt-transition);
+}
+
+/* Collapsed state */
+.modTools .modToolsSection.collapsed .collapseToggle img {
+ transform: rotate(-90deg);
+}
+
+.modTools .modToolsSection.collapsed .sectionHeader {
+ margin-bottom: 0;
+ padding-bottom: var(--mt-space-md);
+}
+
+/* Section content - animated collapse */
+.modTools .sectionContent {
+ overflow: hidden;
+ transition: max-height 0.3s ease-out, opacity 0.2s ease-out;
+ max-height: 5000px; /* Large enough for any content */
+ opacity: 1;
+}
+
+.modTools .modToolsSection.collapsed .sectionContent {
+ max-height: 0;
+ opacity: 0;
+ pointer-events: none;
+}
+
+/* ==========================================================================
+ 11. HELP BUTTON & MODAL
+ ========================================================================== */
+
+/* Help Button - in section header actions */
+.modTools .helpButton {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--mt-bg-subtle);
+ border: 1.5px solid var(--mt-border);
+ color: var(--mt-text-secondary);
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--mt-transition);
+ font-family: var(--mt-font-body);
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.modTools .helpButton:hover {
+ background: var(--mt-primary);
+ border-color: var(--mt-primary);
+ color: var(--mt-text-on-primary);
+ transform: scale(1.05);
+}
+
+.modTools .helpButton:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--mt-primary-light);
+}
+
+/* Modal Overlay */
+.modTools .helpModal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: var(--mt-space-lg);
+ animation: mt-fadeIn 0.15s ease-out;
+}
+
+@keyframes mt-fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* Modal Container */
+.modTools .helpModal {
+ background: var(--mt-bg-card);
+ border-radius: var(--mt-radius-lg);
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
+ max-width: 680px;
+ width: 100%;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ animation: mt-slideUp 0.2s ease-out;
+}
+
+@keyframes mt-slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Modal Header */
+.modTools .helpModal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--mt-space-lg) var(--mt-space-xl);
+ border-bottom: 1px solid var(--mt-border);
+ flex-shrink: 0;
+}
+
+.modTools .helpModal-title {
+ font-family: var(--mt-font-display);
+ font-size: var(--mt-text-2xl);
+ font-weight: var(--mt-font-semibold);
+ color: var(--mt-primary);
+ margin: 0;
+ line-height: 1.3;
+}
+
+.modTools .helpModal-close {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: none;
+ background: var(--mt-bg-subtle);
+ color: var(--mt-text-secondary);
+ font-size: 24px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--mt-transition);
+}
+
+.modTools .helpModal-close:hover {
+ background: var(--mt-error-bg);
+ color: var(--mt-error);
+}
+
+/* Modal Body - Scrollable */
+.modTools .helpModal-body {
+ padding: var(--mt-space-xl);
+ overflow-y: auto;
+ flex: 1;
+ font-size: 15px;
+ line-height: 1.7;
+ color: var(--mt-text);
+}
+
+/* Content styling within modal */
+.modTools .helpModal-body h3 {
+ font-family: var(--mt-font-body);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--mt-primary);
+ margin: var(--mt-space-lg) 0 var(--mt-space-md) 0;
+ padding-bottom: var(--mt-space-sm);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .helpModal-body h3:first-child {
+ margin-top: 0;
+}
+
+.modTools .helpModal-body p {
+ margin: 0 0 var(--mt-space-md) 0;
+}
+
+.modTools .helpModal-body ul,
+.modTools .helpModal-body ol {
+ margin: 0 0 var(--mt-space-md) 0;
+ padding-left: var(--mt-space-xl);
+}
+
+.modTools .helpModal-body li {
+ margin-bottom: var(--mt-space-sm);
+}
+
+.modTools .helpModal-body li:last-child {
+ margin-bottom: 0;
+}
+
+.modTools .helpModal-body strong {
+ font-weight: 600;
+ color: var(--mt-text);
+}
+
+.modTools .helpModal-body code {
+ font-family: var(--mt-font-mono);
+ font-size: 13px;
+ background: var(--mt-bg-subtle);
+ padding: 2px 6px;
+ border-radius: 4px;
+ color: var(--mt-primary);
+}
+
+.modTools .helpModal-body .warning {
+ background: var(--mt-warning-bg);
+ border: 1px solid var(--mt-warning-border);
+ border-radius: var(--mt-radius-md);
+ padding: var(--mt-space-md);
+ margin: var(--mt-space-md) 0;
+ color: var(--mt-warning-text);
+}
+
+.modTools .helpModal-body .warning strong {
+ color: var(--mt-warning);
+}
+
+.modTools .helpModal-body .info {
+ background: var(--mt-info-bg);
+ border: 1px solid var(--mt-info-border);
+ border-radius: var(--mt-radius-md);
+ padding: var(--mt-space-md);
+ margin: var(--mt-space-md) 0;
+ color: var(--mt-info-text);
+}
+
+.modTools .helpModal-body .field-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: var(--mt-space-md) 0;
+ font-size: 14px;
+}
+
+.modTools .helpModal-body .field-table th,
+.modTools .helpModal-body .field-table td {
+ text-align: left;
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .helpModal-body .field-table th {
+ background: var(--mt-bg-subtle);
+ font-weight: 600;
+ color: var(--mt-text);
+}
+
+.modTools .helpModal-body .field-table tr:last-child td {
+ border-bottom: none;
+}
+
+/* Modal Footer */
+.modTools .helpModal-footer {
+ padding: var(--mt-space-md) var(--mt-space-xl);
+ border-top: 1px solid var(--mt-border);
+ display: flex;
+ justify-content: flex-end;
+ flex-shrink: 0;
+}
+
+/* ==========================================================================
+ Print Styles
+ ========================================================================== */
+@media print {
+ .modTools {
+ background: white;
+ }
+
+ .modTools::before {
+ display: none;
+ }
+
+ .modTools .modToolsSection {
+ box-shadow: none;
+ border: 1px solid #ddd;
+ break-inside: avoid;
+ }
+
+ .modTools .modToolsSection.collapsed .sectionContent {
+ max-height: none;
+ opacity: 1;
+ }
+
+ .modTools .modtoolsButton {
+ display: none;
+ }
+
+ .modTools .helpButton,
+ .modTools .collapseToggle {
+ display: none;
+ }
+
+ .modTools .helpModal-overlay {
+ display: none;
+ }
+}
diff --git a/static/js/ModeratorToolsPanel.jsx b/static/js/ModeratorToolsPanel.jsx
index 9411719b7b..ce09dc28bf 100644
--- a/static/js/ModeratorToolsPanel.jsx
+++ b/static/js/ModeratorToolsPanel.jsx
@@ -18,12 +18,10 @@
* - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
* - See /docs/modtools/COMPONENT_LOGIC.md for implementation details
*
- * CSS: Styles are in /static/css/modtools.css
+ * CSS: Styles are in /static/css/static.css (search for "ModTools Design System")
*/
import Sefaria from './sefaria/sefaria';
-// Note: modtools.css is loaded via tag in base.html for CI compatibility
-
// Import tool components
import BulkDownloadText from './modtools/components/BulkDownloadText';
import BulkUploadCSV from './modtools/components/BulkUploadCSV';
diff --git a/static/js/modtools/components/RemoveLinksFromCsv.jsx b/static/js/modtools/components/RemoveLinksFromCsv.jsx
index 0dc345175b..a038f0a479 100644
--- a/static/js/modtools/components/RemoveLinksFromCsv.jsx
+++ b/static/js/modtools/components/RemoveLinksFromCsv.jsx
@@ -10,7 +10,6 @@ import React, { useState } from 'react';
import Cookies from 'js-cookie';
import { saveAs } from 'file-saver';
import ModToolsSection from './shared/ModToolsSection';
-import { stripHtmlTags } from '../utils';
/**
* Help content for RemoveLinksFromCsv
@@ -116,7 +115,7 @@ const RemoveLinksFromCsv = () => {
if (!response.ok) {
response.text().then(resp_text => {
setUploadMessage(null);
- setErrorMessage(stripHtmlTags(resp_text));
+ setErrorMessage(resp_text.stripHtml());
});
} else {
response.json().then(resp_json => {
diff --git a/static/js/modtools/components/UploadLinksFromCSV.jsx b/static/js/modtools/components/UploadLinksFromCSV.jsx
index 2ec926f9e8..5a4acd4140 100644
--- a/static/js/modtools/components/UploadLinksFromCSV.jsx
+++ b/static/js/modtools/components/UploadLinksFromCSV.jsx
@@ -11,7 +11,6 @@ import Component from 'react-class';
import Cookies from 'js-cookie';
import { saveAs } from 'file-saver';
import ModToolsSection from './shared/ModToolsSection';
-import { stripHtmlTags } from '../utils';
/**
* Help content for UploadLinksFromCSV
@@ -175,7 +174,7 @@ class UploadLinksFromCSV extends Component {
uploading: false,
uploadMessage: "",
error: true,
- uploadResult: stripHtmlTags(resp_text)
+ uploadResult: resp_text.stripHtml()
});
});
} else {
diff --git a/static/js/modtools/components/WorkflowyModeratorTool.jsx b/static/js/modtools/components/WorkflowyModeratorTool.jsx
index 4d3c4d7e6a..a0b8feffe5 100644
--- a/static/js/modtools/components/WorkflowyModeratorTool.jsx
+++ b/static/js/modtools/components/WorkflowyModeratorTool.jsx
@@ -15,7 +15,6 @@ import Component from 'react-class';
import Cookies from 'js-cookie';
import { InterfaceText, EnglishText, HebrewText } from '../../Misc';
import ModToolsSection from './shared/ModToolsSection';
-import { stripHtmlTags } from '../utils';
/**
* Help content for WorkflowyModeratorTool
@@ -175,7 +174,7 @@ class WorkflowyModeratorTool extends Component {
this.setState({
uploading: false,
error: true,
- uploadResult: stripHtmlTags(resp_text)
+ uploadResult: resp_text.stripHtml()
});
});
} else {
diff --git a/static/js/modtools/utils/index.js b/static/js/modtools/utils/index.js
deleted file mode 100644
index 7f15ceca06..0000000000
--- a/static/js/modtools/utils/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * ModTools Utilities
- *
- * Shared utility functions used across modtools components.
- */
-
-export { default as stripHtmlTags } from './stripHtmlTags';
diff --git a/static/js/modtools/utils/stripHtmlTags.js b/static/js/modtools/utils/stripHtmlTags.js
deleted file mode 100644
index 13f7bcda69..0000000000
--- a/static/js/modtools/utils/stripHtmlTags.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Strip HTML tags from a string for safe display
- * Uses regex to remove HTML tags - safe approach without DOM parsing
- *
- * @param {string} text - The string potentially containing HTML tags
- * @returns {string} - The string with HTML tags removed
- */
-const stripHtmlTags = (text) => {
- if (!text) return '';
- // Remove HTML tags using regex
- return text
- .replace(/<[^>]*>/g, '')
- .replace(/ /g, ' ')
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .trim();
-};
-
-export default stripHtmlTags;
diff --git a/templates/base.html b/templates/base.html
index d46043199b..96033b7269 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -117,8 +117,6 @@
-
-
{% block static_css %}
{% if renderStatic %}
From b9463b93eb7f9a335e8f4f40195392fe692aaec7 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Mon, 19 Jan 2026 21:00:43 +0200
Subject: [PATCH 22/26] chore: trigger CI
Co-Authored-By: Claude Opus 4.5
From 559f7a1fca9878fc7c915f246401cc182e73b36c Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Tue, 20 Jan 2026 08:46:39 +0200
Subject: [PATCH 23/26] fix: enable static.css loading for modtools page
Set renderStatic=True in the modtools view so that static.css
(which now contains the modtools CSS) is loaded on the page.
Co-Authored-By: Claude Opus 4.5
---
reader/views.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/reader/views.py b/reader/views.py
index e464117b15..c6f17d805d 100644
--- a/reader/views.py
+++ b/reader/views.py
@@ -1215,7 +1215,13 @@ def notifications(request):
@staff_member_required
def modtools(request):
title = _("Moderator Tools")
- return menu_page(request, page="modtools", title=title)
+ props = {"initialMenu": "modtools"}
+ return render_template(request, 'base.html', props, {
+ "title": title,
+ "desc": "",
+ "canonical_url": canonical_url(request),
+ "renderStatic": True,
+ })
def canonical_url(request):
From 5ca945b0f15031300b426f7ec47d6691f3c3b9ba Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Tue, 20 Jan 2026 12:22:30 +0200
Subject: [PATCH 24/26] refactor: move modtools CSS from static.css to s2.css
Move modtools CSS to s2.css instead of static.css because:
- s2.css is the main React app CSS, always loaded for app pages
- static.css is only loaded when renderStatic=True (for static Django pages)
- modtools is a React component, not a static page, so s2.css is
semantically correct
This also reverts the renderStatic=True workaround in the modtools view.
Co-Authored-By: Claude Opus 4.5
---
reader/views.py | 8 +-
static/css/s2.css | 1652 +++++++++++++++++++++++++++++
static/css/static.css | 1652 -----------------------------
static/js/ModeratorToolsPanel.jsx | 2 +-
4 files changed, 1654 insertions(+), 1660 deletions(-)
diff --git a/reader/views.py b/reader/views.py
index c6f17d805d..e464117b15 100644
--- a/reader/views.py
+++ b/reader/views.py
@@ -1215,13 +1215,7 @@ def notifications(request):
@staff_member_required
def modtools(request):
title = _("Moderator Tools")
- props = {"initialMenu": "modtools"}
- return render_template(request, 'base.html', props, {
- "title": title,
- "desc": "",
- "canonical_url": canonical_url(request),
- "renderStatic": True,
- })
+ return menu_page(request, page="modtools", title=title)
def canonical_url(request):
diff --git a/static/css/s2.css b/static/css/s2.css
index d4297bab83..a1ff7ed351 100644
--- a/static/css/s2.css
+++ b/static/css/s2.css
@@ -15720,3 +15720,1655 @@ span.ref-link-color-3 {color: blue}
}
/* ========== GUIDE OVERLAY COMPONENT STYLES - END ========== */
+/**
+ * ModTools Design System
+ * ======================
+ * A refined admin dashboard with scholarly character.
+ *
+ * STRUCTURE:
+ * 1. Design Tokens (CSS Variables)
+ * 2. Base & Reset
+ * 3. Layout Components (Section, Cards)
+ * 4. Form Elements (Inputs, Selects, Buttons)
+ * 5. Layout Patterns (SearchRow, FilterRow, ActionRow)
+ * 6. Data Display (IndexSelector, NodeList)
+ * 7. Feedback (Alerts, Messages, Status)
+ * 8. Utilities
+ * 9. Responsive
+ *
+ * NAMING CONVENTION:
+ * - All classes prefixed with context (e.g., .modTools .searchRow)
+ * - Variables prefixed with --mt- (mod tools)
+ * - BEM-lite: .component, .component-element, .component.modifier
+ */
+
+/* Google Fonts - Scholarly + Modern pairing */
+@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
+
+/* ==========================================================================
+ 1. DESIGN TOKENS
+ ========================================================================== */
+:root {
+ /*
+ * COLOR PALETTE
+ * Using Sefaria design system colors from s2.css
+ */
+
+ /* Background colors */
+ --mt-bg-page: var(--lightest-grey); /* Main page background */
+ --mt-bg-card: #FFFFFF; /* Card/section background */
+ --mt-bg-subtle: var(--lighter-grey); /* Subtle background for groupings */
+ --mt-bg-input: var(--lightest-grey); /* Input field background */
+ --mt-bg-hover: var(--lighter-grey); /* Hover state background */
+
+ /* Brand colors */
+ --mt-primary: var(--sefaria-blue); /* Primary actions, headings */
+ --mt-primary-hover: #122B4A; /* Primary hover state (darker sefaria-blue) */
+ --mt-primary-light: rgba(24, 52, 93, 0.08); /* Primary tint for backgrounds */
+ --mt-accent: var(--inline-link-blue); /* Accent/links */
+ --mt-accent-hover: #3A5FA6; /* Accent hover */
+ --mt-accent-light: rgba(72, 113, 191, 0.1); /* Accent tint */
+
+ /* Text colors */
+ --mt-text: var(--darkest-grey); /* Primary text */
+ --mt-text-secondary: var(--dark-grey); /* Secondary/supporting text */
+ --mt-text-muted: var(--medium-grey); /* Muted/placeholder text */
+ --mt-text-on-primary: #FFFFFF; /* Text on primary color */
+
+ /* Border colors */
+ --mt-border: var(--lighter-grey); /* Default border */
+ --mt-border-hover: var(--light-grey); /* Border on hover */
+ --mt-border-focus: var(--mt-primary); /* Border on focus */
+
+ /* Status colors - Success */
+ --mt-success: #059669;
+ --mt-success-bg: #ECFDF5;
+ --mt-success-border: #A7F3D0;
+ --mt-success-text: #065F46;
+
+ /* Status colors - Warning */
+ --mt-warning: #D97706;
+ --mt-warning-bg: #FFFBEB;
+ --mt-warning-border: #FDE68A;
+ --mt-warning-text: #92400E;
+
+ /* Status colors - Error */
+ --mt-error: #DC2626;
+ --mt-error-bg: #FEF2F2;
+ --mt-error-border: #FECACA;
+ --mt-error-text: #991B1B;
+
+ /* Status colors - Info */
+ --mt-info: #0891B2;
+ --mt-info-bg: #ECFEFF;
+ --mt-info-border: #A5F3FC;
+ --mt-info-text: #0E7490;
+
+ /*
+ * TYPOGRAPHY
+ */
+
+ /* Font families */
+ --mt-font-display: "Crimson Pro", "Georgia", serif;
+ --mt-font-body: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif;
+ --mt-font-hebrew: "Heebo", "Arial Hebrew", sans-serif;
+ --mt-font-mono: "JetBrains Mono", "Fira Code", monospace;
+
+ /* Font sizes - Using a modular scale */
+ --mt-text-xs: 11px; /* Small labels, badges */
+ --mt-text-sm: 13px; /* Help text, meta */
+ --mt-text-base: 15px; /* Body text */
+ --mt-text-md: 16px; /* Slightly larger body */
+ --mt-text-lg: 18px; /* Section intros */
+ --mt-text-xl: 20px; /* Mobile headings */
+ --mt-text-2xl: 24px; /* Section titles */
+
+ /* Font weights */
+ --mt-font-normal: 400;
+ --mt-font-medium: 500;
+ --mt-font-semibold: 600;
+ --mt-font-bold: 700;
+
+ /* Line heights */
+ --mt-leading-tight: 1.3;
+ --mt-leading-normal: 1.5;
+ --mt-leading-relaxed: 1.6;
+
+ /*
+ * SPACING
+ * Based on 4px grid - Compact design
+ */
+ --mt-space-xs: 2px; /* Tight spacing */
+ --mt-space-sm: 4px; /* Small gaps */
+ --mt-space-md: 8px; /* Medium gaps, default padding */
+ --mt-space-lg: 12px; /* Large gaps, section spacing */
+ --mt-space-xl: 16px; /* Extra large, card padding */
+ --mt-space-2xl: 24px; /* Major section breaks */
+
+ /*
+ * BORDERS & EFFECTS
+ */
+
+ /* Border radius */
+ --mt-radius-sm: 6px; /* Small elements, badges */
+ --mt-radius-md: 10px; /* Inputs, buttons */
+ --mt-radius-lg: 14px; /* Cards, sections */
+
+ /* Border width */
+ --mt-border-width: 1px;
+ --mt-border-width-thick: 1.5px;
+ --mt-border-width-heavy: 2px;
+
+ /* Shadows */
+ --mt-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
+ --mt-shadow-card: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
+ --mt-shadow-elevated: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
+ --mt-shadow-focus: 0 0 0 4px var(--mt-primary-light);
+
+ /*
+ * ANIMATION
+ */
+ --mt-transition-fast: 100ms cubic-bezier(0.4, 0, 0.2, 1);
+ --mt-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --mt-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ /*
+ * LAYOUT
+ */
+ --mt-max-width: 1100px;
+ --mt-input-height: 36px; /* Standard input height - compact */
+}
+
+/* ==========================================================================
+ 2. BASE & RESET
+ ========================================================================== */
+.modTools {
+ width: 100%;
+ min-height: 100vh;
+ padding: var(--mt-space-lg) var(--mt-space-md);
+ padding-bottom: 40px; /* Bottom margin */
+ background: var(--mt-bg-page);
+ font-family: var(--mt-font-body);
+ font-size: var(--mt-text-sm);
+ line-height: var(--mt-leading-normal);
+ color: var(--mt-text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ box-sizing: border-box;
+}
+
+/*
+ * Inner container with max-width.
+ * The outer .modTools is full-width so users can scroll from the side margins
+ * without inner scrollable elements (like .indexList) capturing the scroll.
+ */
+.modTools .modToolsInner {
+ max-width: var(--mt-max-width);
+ margin: 0 auto;
+}
+
+.modTools *,
+.modTools *::before,
+.modTools *::after {
+ box-sizing: border-box;
+}
+
+/* Page header */
+.modTools .modToolsInner::before {
+ content: "Moderator Tools";
+ display: block;
+ font-family: var(--mt-font-display);
+ font-size: 22px;
+ font-weight: var(--mt-font-semibold);
+ color: var(--mt-primary);
+ padding: var(--mt-space-md) 0;
+ margin-bottom: var(--mt-space-lg);
+ border-bottom: 2px solid var(--mt-border);
+ letter-spacing: -0.01em;
+}
+
+/* ==========================================================================
+ 3. LAYOUT COMPONENTS
+ ========================================================================== */
+
+/* --- Section Cards --- */
+.modTools .modToolsSection {
+ background: var(--mt-bg-card);
+ border-radius: var(--mt-radius-sm);
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-sm);
+ box-shadow: var(--mt-shadow-sm);
+ border: 1px solid var(--mt-border);
+ transition: box-shadow var(--mt-transition);
+ overflow: visible;
+}
+
+.modTools .modToolsSection:hover {
+ box-shadow: var(--mt-shadow-elevated);
+}
+
+/* Section Title */
+.modTools .dlSectionTitle {
+ font-family: var(--mt-font-display);
+ font-size: var(--mt-text-lg);
+ font-weight: var(--mt-font-semibold);
+ color: var(--mt-primary);
+ margin: 0 0 var(--mt-space-xs) 0;
+ padding-bottom: var(--mt-space-sm);
+ border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
+ letter-spacing: -0.01em;
+ line-height: var(--mt-leading-tight);
+}
+
+.modTools .dlSectionTitle .int-he {
+ font-family: var(--mt-font-hebrew);
+ margin-left: var(--mt-space-md);
+ font-size: var(--mt-text-lg);
+ color: var(--mt-text-secondary);
+}
+
+/* Section subtitle/description */
+.modTools .sectionDescription {
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+ margin-bottom: var(--mt-space-md);
+ line-height: var(--mt-leading-normal);
+}
+
+/* ==========================================================================
+ 4. FORM ELEMENTS
+ ========================================================================== */
+
+/* --- Labels --- */
+.modTools label {
+ display: block;
+ font-size: 13px;
+ font-weight: var(--mt-font-medium);
+ color: var(--mt-text);
+ margin-bottom: var(--mt-space-xs);
+}
+
+/* --- Input Base Styles --- */
+.modTools .dlVersionSelect,
+.modTools input[type="text"],
+.modTools input[type="number"],
+.modTools input[type="url"],
+.modTools select,
+.modTools textarea {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ padding: 6px 10px;
+ margin-bottom: var(--mt-space-sm);
+ font-family: var(--mt-font-body);
+ font-size: var(--mt-text-sm);
+ line-height: var(--mt-leading-normal);
+ color: var(--mt-text);
+ background: var(--mt-bg-input);
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-sm);
+ transition: all var(--mt-transition);
+ box-sizing: border-box;
+}
+
+.modTools .dlVersionSelect::placeholder,
+.modTools input::placeholder,
+.modTools textarea::placeholder {
+ color: var(--mt-text-muted);
+}
+
+.modTools .dlVersionSelect:hover,
+.modTools input[type="text"]:hover,
+.modTools input[type="number"]:hover,
+.modTools input[type="url"]:hover,
+.modTools select:hover,
+.modTools textarea:hover {
+ border-color: var(--mt-border-hover);
+ background: var(--mt-bg-card);
+}
+
+.modTools .dlVersionSelect:focus,
+.modTools input[type="text"]:focus,
+.modTools input[type="number"]:focus,
+.modTools input[type="url"]:focus,
+.modTools select:focus,
+.modTools textarea:focus {
+ outline: none;
+ border-color: var(--mt-border-focus);
+ background: var(--mt-bg-card);
+ box-shadow: var(--mt-shadow-focus);
+}
+
+/* Select dropdowns - Clear arrow indicator */
+.modTools select,
+.modTools select.dlVersionSelect {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background-color: var(--mt-bg-input);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2318345D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 14px center;
+ background-size: 14px 14px;
+ padding-right: 44px !important;
+ cursor: pointer;
+}
+
+/* --- Textarea --- */
+.modTools textarea {
+ min-height: 100px;
+ resize: vertical;
+ font-family: var(--mt-font-body);
+ font-size: 14px;
+ line-height: var(--mt-leading-relaxed);
+}
+
+/* ==========================================================================
+ 5. LAYOUT PATTERNS
+ ========================================================================== */
+
+/* --- Input Row (legacy, stacked) --- */
+.modTools .inputRow {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-md);
+ margin-bottom: var(--mt-space-lg);
+}
+
+.modTools .inputRow input,
+.modTools .inputRow select {
+ margin-bottom: 0;
+}
+
+/* Search row - Input + Button inline */
+.modTools .searchRow {
+ display: flex;
+ gap: var(--mt-space-md);
+ align-items: flex-start;
+ margin-bottom: var(--mt-space-lg);
+}
+
+.modTools .searchRow input {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.modTools .searchRow .modtoolsButton {
+ flex-shrink: 0;
+ white-space: nowrap;
+ /* Standard button sizing - not stretched */
+ padding: 6px 14px;
+ min-width: auto;
+ width: auto;
+}
+
+/* Filter row - label inline with dropdown */
+.modTools .filterRow {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+ margin-bottom: var(--mt-space-lg);
+}
+
+.modTools .filterRow label {
+ margin-bottom: 0;
+ white-space: nowrap;
+}
+
+.modTools .filterRow select {
+ margin-bottom: 0;
+ max-width: 200px;
+}
+
+/* Clear search button - centered */
+.modTools .clearSearchRow {
+ display: flex;
+ justify-content: center;
+ margin-bottom: var(--mt-space-lg);
+}
+
+/* Action button row - for primary action buttons */
+.modTools .actionRow {
+ display: flex;
+ gap: var(--mt-space-md);
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: var(--mt-space-lg);
+}
+
+/* Separator before delete section */
+.modTools .deleteSectionSeparator {
+ margin-top: var(--mt-space-xl);
+ margin-bottom: var(--mt-space-md);
+ border-top: 1px solid var(--mt-border);
+}
+
+/* Section intro text - for counts, descriptions */
+.modTools .sectionIntro {
+ margin-bottom: var(--mt-space-md);
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--mt-text);
+}
+
+/* Subsection heading */
+.modTools .subsectionHeading {
+ margin-top: var(--mt-space-lg);
+ margin-bottom: var(--mt-space-md);
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--mt-text);
+}
+
+/* Option row - for single option with label */
+.modTools .optionRow {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+ margin-bottom: var(--mt-space-md);
+}
+
+.modTools .optionRow label {
+ margin-bottom: 0;
+ white-space: nowrap;
+ min-width: fit-content;
+}
+
+.modTools .optionRow select,
+.modTools .optionRow input {
+ margin-bottom: 0;
+ flex: 1;
+ max-width: 300px;
+}
+
+/* Node list container - for scrollable lists */
+.modTools .nodeListContainer {
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-md);
+ padding: var(--mt-space-md);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-bg-card);
+}
+
+/* Node item - for individual editable items */
+.modTools .nodeItem {
+ margin-bottom: var(--mt-space-md);
+ padding: var(--mt-space-md);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-sm);
+ border: 1px solid var(--mt-border);
+ transition: all var(--mt-transition);
+}
+
+.modTools .nodeItem:last-child {
+ margin-bottom: 0;
+}
+
+.modTools .nodeItem.modified {
+ background: var(--mt-warning-bg);
+ border-color: var(--mt-warning-border);
+}
+
+.modTools .nodeItem .nodeGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--mt-space-md);
+}
+
+.modTools .nodeItem .nodeMeta {
+ margin-top: var(--mt-space-sm);
+ font-size: 12px;
+ color: var(--mt-text-muted);
+}
+
+.modTools .nodeItem .nodeSharedTitle {
+ margin-bottom: var(--mt-space-sm);
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+}
+
+/* Small label for form fields */
+.modTools .fieldLabel {
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+ margin-bottom: var(--mt-space-xs);
+ font-weight: 500;
+}
+
+/* Validation error on input */
+.modTools input.hasError,
+.modTools select.hasError {
+ border-color: var(--mt-error) !important;
+ background: var(--mt-error-bg);
+}
+
+.modTools .validationHint {
+ font-size: var(--mt-text-sm);
+ color: var(--mt-error);
+ margin-top: var(--mt-space-xs);
+}
+
+/* --- Two-column grid for related fields --- */
+.modTools .formGrid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--mt-space-md);
+}
+
+.modTools .formGrid.fullWidth {
+ grid-column: 1 / -1;
+}
+
+/* ==========================================================================
+ 6. BUTTONS
+ ========================================================================== */
+
+/* --- Primary Button --- */
+.modTools .modtoolsButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--mt-space-xs);
+ padding: 6px 14px;
+ background: var(--mt-primary);
+ color: var(--mt-text-on-primary);
+ font-family: var(--mt-font-body);
+ font-size: 12px;
+ font-weight: 600;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all var(--mt-transition);
+ text-decoration: none;
+ white-space: nowrap;
+ text-align: center;
+}
+
+.modTools .modtoolsButton:hover {
+ background: var(--mt-primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--mt-shadow-sm);
+}
+
+.modTools .modtoolsButton:active {
+ transform: translateY(0);
+}
+
+.modTools .modtoolsButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* Secondary button */
+.modTools .modtoolsButton.secondary {
+ background: transparent;
+ color: var(--mt-primary);
+ border: 2px solid var(--mt-primary);
+ padding: 6px 14px;
+}
+
+.modTools .modtoolsButton.secondary:hover {
+ background: var(--mt-primary-light);
+}
+
+/* Danger button */
+.modTools .modtoolsButton.danger {
+ background: var(--mt-error);
+}
+
+.modTools .modtoolsButton.danger:hover {
+ background: #B91C1C;
+}
+
+/* Small button */
+.modTools .modtoolsButton.small {
+ padding: 5px 10px;
+ font-size: 12px;
+}
+
+/* Loading spinner in buttons */
+.modTools .loadingSpinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: mt-spin 0.7s linear infinite;
+}
+
+@keyframes mt-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Button row */
+.modTools .buttonRow {
+ display: flex;
+ gap: var(--mt-space-md);
+ flex-wrap: wrap;
+ margin-top: var(--mt-space-lg);
+}
+
+/* --- File Upload Zones --- */
+.modTools input[type="file"] {
+ display: block;
+ width: 100%;
+ padding: var(--mt-space-md);
+ margin-bottom: var(--mt-space-sm);
+ background: var(--mt-bg-subtle);
+ border: 2px dashed var(--mt-border);
+ border-radius: var(--mt-radius-sm);
+ cursor: pointer;
+ font-family: var(--mt-font-body);
+ font-size: 13px;
+ color: var(--mt-text-secondary);
+}
+
+.modTools input[type="file"]:hover {
+ border-color: var(--mt-primary);
+ background: var(--mt-primary-light);
+}
+
+.modTools input[type="file"]::file-selector-button {
+ padding: 6px 12px;
+ margin-right: var(--mt-space-sm);
+ background: var(--mt-primary);
+ color: white;
+ border: none;
+ border-radius: var(--mt-radius-sm);
+ font-family: var(--mt-font-body);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background var(--mt-transition);
+}
+
+.modTools input[type="file"]::file-selector-button:hover {
+ background: var(--mt-primary-hover);
+}
+
+/* ==========================================================================
+ 7. DATA DISPLAY COMPONENTS
+ ========================================================================== */
+
+/* --- Index Selector --- */
+.modTools .indexSelectorContainer {
+ margin-top: var(--mt-space-lg);
+ background: var(--mt-bg-card);
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-lg);
+ overflow: hidden;
+}
+
+/* Header */
+.modTools .indexSelectorHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ background: var(--mt-bg-subtle);
+ border-bottom: 1px solid var(--mt-border);
+ flex-wrap: wrap;
+ gap: var(--mt-space-md);
+}
+
+.modTools .indexSelectorTitle {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--mt-text);
+}
+
+.modTools .indexSelectorTitle .highlight {
+ color: var(--mt-primary);
+ font-weight: 700;
+}
+
+.modTools .indexSelectorActions {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-lg);
+}
+
+.modTools .selectionCount {
+ font-size: 14px;
+ color: var(--mt-text-secondary);
+}
+
+.modTools .selectAllToggle {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-sm);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--mt-text);
+ cursor: pointer;
+ margin-bottom: 0;
+}
+
+.modTools .selectAllToggle input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--mt-primary);
+ cursor: pointer;
+}
+
+/* Search input */
+.modTools .indexSearchWrapper {
+ position: relative;
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ background: var(--mt-bg-card);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .indexSearchInput {
+ width: 100%;
+ padding: 10px 40px 10px 16px;
+ margin: 0;
+ font-size: 14px;
+ border: 1.5px solid var(--mt-border);
+ border-radius: var(--mt-radius-md);
+ background: var(--mt-bg-input);
+}
+
+.modTools .indexSearchInput:focus {
+ outline: none;
+ border-color: var(--mt-primary);
+ box-shadow: 0 0 0 3px var(--mt-primary-light);
+}
+
+.modTools .indexSearchClear {
+ position: absolute;
+ right: calc(var(--mt-space-lg) + 12px);
+ top: 50%;
+ transform: translateY(-50%);
+ width: 24px;
+ height: 24px;
+ border: none;
+ background: transparent;
+ color: var(--mt-text-muted);
+ font-size: 18px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+}
+
+.modTools .indexSearchClear:hover {
+ background: var(--mt-bg-subtle);
+ color: var(--mt-text);
+}
+
+/* Index List */
+.modTools .indexList {
+ display: flex;
+ flex-direction: column;
+ max-height: 400px;
+ overflow-y: auto;
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-md);
+ background: var(--mt-bg-card);
+}
+
+/* Custom scrollbar for index list */
+.modTools .indexList::-webkit-scrollbar {
+ width: 8px;
+}
+
+.modTools .indexList::-webkit-scrollbar-track {
+ background: var(--mt-bg-subtle);
+ border-radius: 4px;
+}
+
+.modTools .indexList::-webkit-scrollbar-thumb {
+ background: var(--mt-border);
+ border-radius: 4px;
+}
+
+.modTools .indexList::-webkit-scrollbar-thumb:hover {
+ background: #B5B3AC;
+}
+
+/* Index List Row */
+.modTools .indexListRow {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-md);
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ border-bottom: 1px solid var(--mt-border);
+ cursor: pointer;
+ transition: background var(--mt-transition);
+}
+
+.modTools .indexListRow:last-child {
+ border-bottom: none;
+}
+
+.modTools .indexListRow:hover {
+ background: var(--mt-primary-light);
+}
+
+.modTools .indexListRow.selected {
+ background: rgba(24, 52, 93, 0.08);
+ box-shadow: inset 3px 0 0 var(--mt-primary);
+}
+
+.modTools .indexListRow input[type="checkbox"] {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ accent-color: var(--mt-primary);
+ cursor: pointer;
+}
+
+.modTools .indexListTitle {
+ flex: 1;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--mt-text);
+ line-height: 1.4;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.modTools .indexListCategory {
+ flex-shrink: 0;
+ font-size: 12px;
+ color: var(--mt-text-muted);
+ padding: 2px 8px;
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-sm);
+}
+
+/* No results in search */
+.modTools .indexNoResults {
+ text-align: center;
+ padding: var(--mt-space-xl);
+ color: var(--mt-text-muted);
+ font-size: 14px;
+}
+
+/* Legacy indices list (fallback) */
+.modTools .selectionControls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--mt-space-md);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-md) var(--mt-radius-md) 0 0;
+ border: 1px solid var(--mt-border);
+ border-bottom: none;
+}
+
+.modTools .selectionButtons {
+ display: flex;
+ gap: var(--mt-space-sm);
+}
+
+.modTools .indicesList {
+ max-height: 280px;
+ overflow-y: auto;
+ border: 1px solid var(--mt-border);
+ border-radius: 0 0 var(--mt-radius-md) var(--mt-radius-md);
+ padding: var(--mt-space-sm);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-bg-card);
+ scroll-behavior: smooth;
+}
+
+.modTools .indicesList label {
+ display: flex;
+ align-items: center;
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ margin: 2px 0;
+ cursor: pointer;
+ border-radius: var(--mt-radius-sm);
+ transition: background var(--mt-transition);
+ font-weight: 400;
+ font-size: 14px;
+}
+
+.modTools .indicesList label:hover {
+ background: var(--mt-bg-subtle);
+}
+
+.modTools .indicesList label.selected {
+ background: var(--mt-primary-light);
+ border-left: 3px solid var(--mt-primary);
+}
+
+.modTools .indicesList label input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ margin: 0 var(--mt-space-md) 0 0;
+}
+
+/* ==========================================================================
+ 8. FIELD GROUPS
+ ========================================================================== */
+.modTools .fieldGroup {
+ margin-bottom: var(--mt-space-sm);
+}
+
+.modTools .fieldGroup label {
+ margin-bottom: var(--mt-space-xs);
+}
+
+.modTools .fieldGroup .fieldInput {
+ margin-bottom: var(--mt-space-xs);
+}
+
+.modTools .fieldGroup .fieldInput:disabled {
+ background-color: #f5f5f5;
+ color: #999;
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.modTools .fieldHelp {
+ font-size: 13px;
+ color: var(--mt-text-muted);
+ margin-bottom: var(--mt-space-sm);
+ line-height: 1.5;
+}
+
+/* Field group sections */
+.modTools .fieldGroupSection {
+ margin-bottom: var(--mt-space-md);
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-sm);
+ border: 1px solid var(--mt-border);
+}
+
+.modTools .fieldGroupHeader {
+ font-family: var(--mt-font-body);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--mt-text-muted);
+ margin-bottom: var(--mt-space-md);
+ padding-bottom: var(--mt-space-sm);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .fieldGroupGrid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--mt-space-lg) var(--mt-space-md);
+ align-items: end; /* Align inputs at bottom of each row */
+}
+
+.modTools .fieldGroupGrid .fieldGroup {
+ margin-bottom: 0;
+}
+
+.modTools .fieldGroup.fullWidth {
+ grid-column: 1 / -1;
+}
+
+/* Field name badge */
+.modTools .fieldNameBadge {
+ display: inline-block;
+ font-family: var(--mt-font-mono);
+ font-size: 11px;
+ padding: 2px 6px;
+ background: var(--mt-bg-subtle);
+ border: 1px solid var(--mt-border);
+ border-radius: 4px;
+ color: var(--mt-text-muted);
+ margin-left: var(--mt-space-sm);
+ vertical-align: middle;
+}
+
+/* Validation states */
+.modTools .fieldGroup.hasError input,
+.modTools .fieldGroup.hasError select {
+ border-color: var(--mt-error);
+ background: var(--mt-error-bg);
+}
+
+.modTools .fieldError {
+ font-size: var(--mt-text-sm);
+ color: var(--mt-error);
+ margin-top: var(--mt-space-xs);
+}
+
+.modTools .requiredIndicator {
+ color: var(--mt-error);
+ font-weight: bold;
+ margin-left: 2px;
+}
+
+/* ==========================================================================
+ 9. FEEDBACK & ALERTS
+ ========================================================================== */
+.modTools .message {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-top: var(--mt-space-md);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.modTools .message.success {
+ background: var(--mt-success-bg);
+ color: var(--mt-success);
+ border: 1px solid var(--mt-success-border);
+}
+
+.modTools .message.warning {
+ background: var(--mt-warning-bg);
+ color: var(--mt-warning-text);
+ border: 1px solid var(--mt-warning-border);
+}
+
+.modTools .message.error {
+ background: var(--mt-error-bg);
+ color: var(--mt-error);
+ border: 1px solid var(--mt-error-border);
+}
+
+.modTools .message.info {
+ background: var(--mt-info-bg);
+ color: #0E7490;
+ border: 1px solid var(--mt-info-border);
+}
+
+/* Info/Warning/Danger boxes */
+.modTools .infoBox {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-info-bg);
+ border: 1px solid var(--mt-info-border);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+ color: #0E7490;
+}
+
+.modTools .infoBox strong {
+ color: var(--mt-info);
+}
+
+.modTools .warningBox {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-warning-bg);
+ border: 1px solid var(--mt-warning-border);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+ color: #92400E;
+}
+
+.modTools .warningBox strong {
+ display: block;
+ margin-bottom: var(--mt-space-sm);
+ color: var(--mt-warning);
+ font-size: 15px;
+}
+
+.modTools .warningBox ul {
+ margin: var(--mt-space-sm) 0 0;
+ padding-left: var(--mt-space-lg);
+}
+
+.modTools .warningBox li {
+ margin-bottom: var(--mt-space-sm);
+}
+
+.modTools .warningBox li:last-child {
+ margin-bottom: 0;
+}
+
+.modTools .warningBox li strong {
+ display: inline;
+ margin-bottom: 0;
+}
+
+.modTools .warningBox li p {
+ margin: var(--mt-space-xs) 0 0;
+}
+
+.modTools .dangerBox {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: var(--mt-error-bg);
+ border: 1px solid var(--mt-error-border);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+ color: #991B1B;
+}
+
+.modTools .dangerBox strong {
+ color: var(--mt-error);
+}
+
+/* Changes preview box */
+.modTools .changesPreview {
+ padding: var(--mt-space-md) var(--mt-space-lg);
+ margin-bottom: var(--mt-space-lg);
+ background: linear-gradient(135deg, rgba(72, 113, 191, 0.08) 0%, rgba(24, 52, 93, 0.06) 100%);
+ border: 1px solid rgba(72, 113, 191, 0.3);
+ border-radius: var(--mt-radius-md);
+ font-size: 14px;
+}
+
+.modTools .changesPreview strong {
+ display: block;
+ margin-bottom: var(--mt-space-sm);
+ color: var(--mt-primary);
+}
+
+.modTools .changesPreview ul {
+ margin: var(--mt-space-xs) 0 0 var(--mt-space-lg);
+ padding: 0;
+}
+
+.modTools .changesPreview li {
+ margin-bottom: var(--mt-space-xs);
+ color: var(--mt-text-secondary);
+}
+
+/* No results state */
+.modTools .noResults {
+ padding: var(--mt-space-xl);
+ text-align: center;
+ color: var(--mt-text-muted);
+ background: var(--mt-bg-subtle);
+ border-radius: var(--mt-radius-md);
+ margin-top: var(--mt-space-md);
+}
+
+.modTools .noResults strong {
+ display: block;
+ color: var(--mt-text);
+ margin-bottom: var(--mt-space-sm);
+ font-size: 16px;
+}
+
+/* ==========================================================================
+ Workflowy & Legacy Forms
+ ========================================================================== */
+.modTools .workflowy-tool {
+ width: 100%;
+}
+
+.modTools .workflowy-tool .workflowy-tool-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-md);
+}
+
+.modTools .workflowy-tool textarea {
+ width: 100%;
+ min-height: 200px;
+ font-family: var(--mt-font-mono);
+ font-size: 12px;
+}
+
+.modTools .getLinks,
+.modTools .uploadLinksFromCSV,
+.modTools .remove-links-csv {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-xs);
+}
+
+.modTools .getLinks form,
+.modTools .uploadLinksFromCSV form,
+.modTools .remove-links-csv form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--mt-space-xs);
+}
+
+.modTools .getLinks fieldset {
+ border: 1px solid var(--mt-border);
+ border-radius: var(--mt-radius-sm);
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ margin: 0;
+ background: var(--mt-bg-subtle);
+}
+
+.modTools .getLinks fieldset legend {
+ padding: 0 var(--mt-space-xs);
+ font-weight: 600;
+ font-size: 12px;
+}
+
+/* Submit buttons in legacy forms */
+.modTools input[type="submit"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 14px;
+ background: var(--mt-primary);
+ color: var(--mt-text-on-primary);
+ font-family: var(--mt-font-body);
+ font-size: 12px;
+ font-weight: 600;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all var(--mt-transition);
+ align-self: flex-start;
+}
+
+.modTools input[type="submit"]:hover {
+ background: var(--mt-primary-hover);
+}
+
+.modTools input[type="submit"]:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ==========================================================================
+ Checkbox Styling
+ ========================================================================== */
+.modTools input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ cursor: pointer;
+ accent-color: var(--mt-primary);
+}
+
+.modTools .checkboxLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--mt-space-xs);
+ cursor: pointer;
+ padding: 0;
+ font-weight: 400;
+ font-size: 13px;
+}
+
+.modTools label:has(input[type="checkbox"]) {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--mt-space-xs);
+ font-weight: 400;
+ cursor: pointer;
+ padding: var(--mt-space-xs) 0;
+ font-size: 13px;
+}
+
+/* ==========================================================================
+ 10. COLLAPSIBLE SECTIONS
+ ========================================================================== */
+
+/* Section header - clickable to toggle */
+.modTools .sectionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ user-select: none;
+ padding-bottom: var(--mt-space-md);
+ margin-bottom: var(--mt-space-md);
+ border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
+ transition: all var(--mt-transition);
+}
+
+.modTools .sectionHeader:hover {
+ border-bottom-color: var(--mt-primary);
+}
+
+.modTools .sectionHeader:hover .dlSectionTitle {
+ color: var(--mt-primary-hover);
+}
+
+/* When section header exists, title shouldn't have its own border */
+.modTools .sectionHeader .dlSectionTitle {
+ margin: 0;
+ padding: 0; /* Reset legacy padding from s2.css */
+ border-bottom: none;
+ white-space: nowrap;
+ line-height: 1;
+}
+
+/* Left side: collapse toggle + title */
+.modTools .sectionHeaderLeft {
+ display: flex;
+ align-items: center;
+ gap: var(--mt-space-sm);
+}
+
+/* Right side: help button */
+.modTools .sectionHeaderRight {
+ flex-shrink: 0;
+}
+
+/* Collapse toggle indicator - on the left */
+.modTools .collapseToggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--mt-bg-subtle);
+ border: 1.5px solid var(--mt-border);
+ color: var(--mt-text-secondary);
+ font-size: 14px;
+ transition: all var(--mt-transition);
+ flex-shrink: 0;
+}
+
+.modTools .sectionHeader:hover .collapseToggle {
+ background: var(--mt-primary-light);
+ border-color: var(--mt-primary);
+ color: var(--mt-primary);
+}
+
+.modTools .collapseToggle img {
+ width: 14px;
+ height: 14px;
+ transition: transform var(--mt-transition);
+}
+
+/* Collapsed state */
+.modTools .modToolsSection.collapsed .collapseToggle img {
+ transform: rotate(-90deg);
+}
+
+.modTools .modToolsSection.collapsed .sectionHeader {
+ margin-bottom: 0;
+ padding-bottom: var(--mt-space-md);
+}
+
+/* Section content - animated collapse */
+.modTools .sectionContent {
+ overflow: hidden;
+ transition: max-height 0.3s ease-out, opacity 0.2s ease-out;
+ max-height: 5000px; /* Large enough for any content */
+ opacity: 1;
+}
+
+.modTools .modToolsSection.collapsed .sectionContent {
+ max-height: 0;
+ opacity: 0;
+ pointer-events: none;
+}
+
+/* ==========================================================================
+ 11. HELP BUTTON & MODAL
+ ========================================================================== */
+
+/* Help Button - in section header actions */
+.modTools .helpButton {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--mt-bg-subtle);
+ border: 1.5px solid var(--mt-border);
+ color: var(--mt-text-secondary);
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--mt-transition);
+ font-family: var(--mt-font-body);
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.modTools .helpButton:hover {
+ background: var(--mt-primary);
+ border-color: var(--mt-primary);
+ color: var(--mt-text-on-primary);
+ transform: scale(1.05);
+}
+
+.modTools .helpButton:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--mt-primary-light);
+}
+
+/* Modal Overlay */
+.modTools .helpModal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: var(--mt-space-lg);
+ animation: mt-fadeIn 0.15s ease-out;
+}
+
+@keyframes mt-fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* Modal Container */
+.modTools .helpModal {
+ background: var(--mt-bg-card);
+ border-radius: var(--mt-radius-lg);
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
+ max-width: 680px;
+ width: 100%;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ animation: mt-slideUp 0.2s ease-out;
+}
+
+@keyframes mt-slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Modal Header */
+.modTools .helpModal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--mt-space-lg) var(--mt-space-xl);
+ border-bottom: 1px solid var(--mt-border);
+ flex-shrink: 0;
+}
+
+.modTools .helpModal-title {
+ font-family: var(--mt-font-display);
+ font-size: var(--mt-text-2xl);
+ font-weight: var(--mt-font-semibold);
+ color: var(--mt-primary);
+ margin: 0;
+ line-height: 1.3;
+}
+
+.modTools .helpModal-close {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: none;
+ background: var(--mt-bg-subtle);
+ color: var(--mt-text-secondary);
+ font-size: 24px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--mt-transition);
+}
+
+.modTools .helpModal-close:hover {
+ background: var(--mt-error-bg);
+ color: var(--mt-error);
+}
+
+/* Modal Body - Scrollable */
+.modTools .helpModal-body {
+ padding: var(--mt-space-xl);
+ overflow-y: auto;
+ flex: 1;
+ font-size: 15px;
+ line-height: 1.7;
+ color: var(--mt-text);
+}
+
+/* Content styling within modal */
+.modTools .helpModal-body h3 {
+ font-family: var(--mt-font-body);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--mt-primary);
+ margin: var(--mt-space-lg) 0 var(--mt-space-md) 0;
+ padding-bottom: var(--mt-space-sm);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .helpModal-body h3:first-child {
+ margin-top: 0;
+}
+
+.modTools .helpModal-body p {
+ margin: 0 0 var(--mt-space-md) 0;
+}
+
+.modTools .helpModal-body ul,
+.modTools .helpModal-body ol {
+ margin: 0 0 var(--mt-space-md) 0;
+ padding-left: var(--mt-space-xl);
+}
+
+.modTools .helpModal-body li {
+ margin-bottom: var(--mt-space-sm);
+}
+
+.modTools .helpModal-body li:last-child {
+ margin-bottom: 0;
+}
+
+.modTools .helpModal-body strong {
+ font-weight: 600;
+ color: var(--mt-text);
+}
+
+.modTools .helpModal-body code {
+ font-family: var(--mt-font-mono);
+ font-size: 13px;
+ background: var(--mt-bg-subtle);
+ padding: 2px 6px;
+ border-radius: 4px;
+ color: var(--mt-primary);
+}
+
+.modTools .helpModal-body .warning {
+ background: var(--mt-warning-bg);
+ border: 1px solid var(--mt-warning-border);
+ border-radius: var(--mt-radius-md);
+ padding: var(--mt-space-md);
+ margin: var(--mt-space-md) 0;
+ color: var(--mt-warning-text);
+}
+
+.modTools .helpModal-body .warning strong {
+ color: var(--mt-warning);
+}
+
+.modTools .helpModal-body .info {
+ background: var(--mt-info-bg);
+ border: 1px solid var(--mt-info-border);
+ border-radius: var(--mt-radius-md);
+ padding: var(--mt-space-md);
+ margin: var(--mt-space-md) 0;
+ color: var(--mt-info-text);
+}
+
+.modTools .helpModal-body .field-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: var(--mt-space-md) 0;
+ font-size: 14px;
+}
+
+.modTools .helpModal-body .field-table th,
+.modTools .helpModal-body .field-table td {
+ text-align: left;
+ padding: var(--mt-space-sm) var(--mt-space-md);
+ border-bottom: 1px solid var(--mt-border);
+}
+
+.modTools .helpModal-body .field-table th {
+ background: var(--mt-bg-subtle);
+ font-weight: 600;
+ color: var(--mt-text);
+}
+
+.modTools .helpModal-body .field-table tr:last-child td {
+ border-bottom: none;
+}
+
+/* Modal Footer */
+.modTools .helpModal-footer {
+ padding: var(--mt-space-md) var(--mt-space-xl);
+ border-top: 1px solid var(--mt-border);
+ display: flex;
+ justify-content: flex-end;
+ flex-shrink: 0;
+}
+
+/* ==========================================================================
+ Print Styles
+ ========================================================================== */
+@media print {
+ .modTools {
+ background: white;
+ }
+
+ .modTools::before {
+ display: none;
+ }
+
+ .modTools .modToolsSection {
+ box-shadow: none;
+ border: 1px solid #ddd;
+ break-inside: avoid;
+ }
+
+ .modTools .modToolsSection.collapsed .sectionContent {
+ max-height: none;
+ opacity: 1;
+ }
+
+ .modTools .modtoolsButton {
+ display: none;
+ }
+
+ .modTools .helpButton,
+ .modTools .collapseToggle {
+ display: none;
+ }
+
+ .modTools .helpModal-overlay {
+ display: none;
+ }
+}
diff --git a/static/css/static.css b/static/css/static.css
index b91322b574..9328fc2387 100644
--- a/static/css/static.css
+++ b/static/css/static.css
@@ -3688,1655 +3688,3 @@ form.globalUpdateForm + div.notificationsList {
.updateTextarea {
width: 100%;
}
-/**
- * ModTools Design System
- * ======================
- * A refined admin dashboard with scholarly character.
- *
- * STRUCTURE:
- * 1. Design Tokens (CSS Variables)
- * 2. Base & Reset
- * 3. Layout Components (Section, Cards)
- * 4. Form Elements (Inputs, Selects, Buttons)
- * 5. Layout Patterns (SearchRow, FilterRow, ActionRow)
- * 6. Data Display (IndexSelector, NodeList)
- * 7. Feedback (Alerts, Messages, Status)
- * 8. Utilities
- * 9. Responsive
- *
- * NAMING CONVENTION:
- * - All classes prefixed with context (e.g., .modTools .searchRow)
- * - Variables prefixed with --mt- (mod tools)
- * - BEM-lite: .component, .component-element, .component.modifier
- */
-
-/* Google Fonts - Scholarly + Modern pairing */
-@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
-
-/* ==========================================================================
- 1. DESIGN TOKENS
- ========================================================================== */
-:root {
- /*
- * COLOR PALETTE
- * Using Sefaria design system colors from s2.css
- */
-
- /* Background colors */
- --mt-bg-page: var(--lightest-grey); /* Main page background */
- --mt-bg-card: #FFFFFF; /* Card/section background */
- --mt-bg-subtle: var(--lighter-grey); /* Subtle background for groupings */
- --mt-bg-input: var(--lightest-grey); /* Input field background */
- --mt-bg-hover: var(--lighter-grey); /* Hover state background */
-
- /* Brand colors */
- --mt-primary: var(--sefaria-blue); /* Primary actions, headings */
- --mt-primary-hover: #122B4A; /* Primary hover state (darker sefaria-blue) */
- --mt-primary-light: rgba(24, 52, 93, 0.08); /* Primary tint for backgrounds */
- --mt-accent: var(--inline-link-blue); /* Accent/links */
- --mt-accent-hover: #3A5FA6; /* Accent hover */
- --mt-accent-light: rgba(72, 113, 191, 0.1); /* Accent tint */
-
- /* Text colors */
- --mt-text: var(--darkest-grey); /* Primary text */
- --mt-text-secondary: var(--dark-grey); /* Secondary/supporting text */
- --mt-text-muted: var(--medium-grey); /* Muted/placeholder text */
- --mt-text-on-primary: #FFFFFF; /* Text on primary color */
-
- /* Border colors */
- --mt-border: var(--lighter-grey); /* Default border */
- --mt-border-hover: var(--light-grey); /* Border on hover */
- --mt-border-focus: var(--mt-primary); /* Border on focus */
-
- /* Status colors - Success */
- --mt-success: #059669;
- --mt-success-bg: #ECFDF5;
- --mt-success-border: #A7F3D0;
- --mt-success-text: #065F46;
-
- /* Status colors - Warning */
- --mt-warning: #D97706;
- --mt-warning-bg: #FFFBEB;
- --mt-warning-border: #FDE68A;
- --mt-warning-text: #92400E;
-
- /* Status colors - Error */
- --mt-error: #DC2626;
- --mt-error-bg: #FEF2F2;
- --mt-error-border: #FECACA;
- --mt-error-text: #991B1B;
-
- /* Status colors - Info */
- --mt-info: #0891B2;
- --mt-info-bg: #ECFEFF;
- --mt-info-border: #A5F3FC;
- --mt-info-text: #0E7490;
-
- /*
- * TYPOGRAPHY
- */
-
- /* Font families */
- --mt-font-display: "Crimson Pro", "Georgia", serif;
- --mt-font-body: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif;
- --mt-font-hebrew: "Heebo", "Arial Hebrew", sans-serif;
- --mt-font-mono: "JetBrains Mono", "Fira Code", monospace;
-
- /* Font sizes - Using a modular scale */
- --mt-text-xs: 11px; /* Small labels, badges */
- --mt-text-sm: 13px; /* Help text, meta */
- --mt-text-base: 15px; /* Body text */
- --mt-text-md: 16px; /* Slightly larger body */
- --mt-text-lg: 18px; /* Section intros */
- --mt-text-xl: 20px; /* Mobile headings */
- --mt-text-2xl: 24px; /* Section titles */
-
- /* Font weights */
- --mt-font-normal: 400;
- --mt-font-medium: 500;
- --mt-font-semibold: 600;
- --mt-font-bold: 700;
-
- /* Line heights */
- --mt-leading-tight: 1.3;
- --mt-leading-normal: 1.5;
- --mt-leading-relaxed: 1.6;
-
- /*
- * SPACING
- * Based on 4px grid - Compact design
- */
- --mt-space-xs: 2px; /* Tight spacing */
- --mt-space-sm: 4px; /* Small gaps */
- --mt-space-md: 8px; /* Medium gaps, default padding */
- --mt-space-lg: 12px; /* Large gaps, section spacing */
- --mt-space-xl: 16px; /* Extra large, card padding */
- --mt-space-2xl: 24px; /* Major section breaks */
-
- /*
- * BORDERS & EFFECTS
- */
-
- /* Border radius */
- --mt-radius-sm: 6px; /* Small elements, badges */
- --mt-radius-md: 10px; /* Inputs, buttons */
- --mt-radius-lg: 14px; /* Cards, sections */
-
- /* Border width */
- --mt-border-width: 1px;
- --mt-border-width-thick: 1.5px;
- --mt-border-width-heavy: 2px;
-
- /* Shadows */
- --mt-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
- --mt-shadow-card: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
- --mt-shadow-elevated: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
- --mt-shadow-focus: 0 0 0 4px var(--mt-primary-light);
-
- /*
- * ANIMATION
- */
- --mt-transition-fast: 100ms cubic-bezier(0.4, 0, 0.2, 1);
- --mt-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
- --mt-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
-
- /*
- * LAYOUT
- */
- --mt-max-width: 1100px;
- --mt-input-height: 36px; /* Standard input height - compact */
-}
-
-/* ==========================================================================
- 2. BASE & RESET
- ========================================================================== */
-.modTools {
- width: 100%;
- min-height: 100vh;
- padding: var(--mt-space-lg) var(--mt-space-md);
- padding-bottom: 40px; /* Bottom margin */
- background: var(--mt-bg-page);
- font-family: var(--mt-font-body);
- font-size: var(--mt-text-sm);
- line-height: var(--mt-leading-normal);
- color: var(--mt-text);
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- box-sizing: border-box;
-}
-
-/*
- * Inner container with max-width.
- * The outer .modTools is full-width so users can scroll from the side margins
- * without inner scrollable elements (like .indexList) capturing the scroll.
- */
-.modTools .modToolsInner {
- max-width: var(--mt-max-width);
- margin: 0 auto;
-}
-
-.modTools *,
-.modTools *::before,
-.modTools *::after {
- box-sizing: border-box;
-}
-
-/* Page header */
-.modTools .modToolsInner::before {
- content: "Moderator Tools";
- display: block;
- font-family: var(--mt-font-display);
- font-size: 22px;
- font-weight: var(--mt-font-semibold);
- color: var(--mt-primary);
- padding: var(--mt-space-md) 0;
- margin-bottom: var(--mt-space-lg);
- border-bottom: 2px solid var(--mt-border);
- letter-spacing: -0.01em;
-}
-
-/* ==========================================================================
- 3. LAYOUT COMPONENTS
- ========================================================================== */
-
-/* --- Section Cards --- */
-.modTools .modToolsSection {
- background: var(--mt-bg-card);
- border-radius: var(--mt-radius-sm);
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-sm);
- box-shadow: var(--mt-shadow-sm);
- border: 1px solid var(--mt-border);
- transition: box-shadow var(--mt-transition);
- overflow: visible;
-}
-
-.modTools .modToolsSection:hover {
- box-shadow: var(--mt-shadow-elevated);
-}
-
-/* Section Title */
-.modTools .dlSectionTitle {
- font-family: var(--mt-font-display);
- font-size: var(--mt-text-lg);
- font-weight: var(--mt-font-semibold);
- color: var(--mt-primary);
- margin: 0 0 var(--mt-space-xs) 0;
- padding-bottom: var(--mt-space-sm);
- border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
- letter-spacing: -0.01em;
- line-height: var(--mt-leading-tight);
-}
-
-.modTools .dlSectionTitle .int-he {
- font-family: var(--mt-font-hebrew);
- margin-left: var(--mt-space-md);
- font-size: var(--mt-text-lg);
- color: var(--mt-text-secondary);
-}
-
-/* Section subtitle/description */
-.modTools .sectionDescription {
- font-size: 13px;
- color: var(--mt-text-secondary);
- margin-bottom: var(--mt-space-md);
- line-height: var(--mt-leading-normal);
-}
-
-/* ==========================================================================
- 4. FORM ELEMENTS
- ========================================================================== */
-
-/* --- Labels --- */
-.modTools label {
- display: block;
- font-size: 13px;
- font-weight: var(--mt-font-medium);
- color: var(--mt-text);
- margin-bottom: var(--mt-space-xs);
-}
-
-/* --- Input Base Styles --- */
-.modTools .dlVersionSelect,
-.modTools input[type="text"],
-.modTools input[type="number"],
-.modTools input[type="url"],
-.modTools select,
-.modTools textarea {
- display: block;
- width: 100%;
- max-width: 100%;
- padding: 6px 10px;
- margin-bottom: var(--mt-space-sm);
- font-family: var(--mt-font-body);
- font-size: var(--mt-text-sm);
- line-height: var(--mt-leading-normal);
- color: var(--mt-text);
- background: var(--mt-bg-input);
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-sm);
- transition: all var(--mt-transition);
- box-sizing: border-box;
-}
-
-.modTools .dlVersionSelect::placeholder,
-.modTools input::placeholder,
-.modTools textarea::placeholder {
- color: var(--mt-text-muted);
-}
-
-.modTools .dlVersionSelect:hover,
-.modTools input[type="text"]:hover,
-.modTools input[type="number"]:hover,
-.modTools input[type="url"]:hover,
-.modTools select:hover,
-.modTools textarea:hover {
- border-color: var(--mt-border-hover);
- background: var(--mt-bg-card);
-}
-
-.modTools .dlVersionSelect:focus,
-.modTools input[type="text"]:focus,
-.modTools input[type="number"]:focus,
-.modTools input[type="url"]:focus,
-.modTools select:focus,
-.modTools textarea:focus {
- outline: none;
- border-color: var(--mt-border-focus);
- background: var(--mt-bg-card);
- box-shadow: var(--mt-shadow-focus);
-}
-
-/* Select dropdowns - Clear arrow indicator */
-.modTools select,
-.modTools select.dlVersionSelect {
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
- background-color: var(--mt-bg-input);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2318345D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 14px center;
- background-size: 14px 14px;
- padding-right: 44px !important;
- cursor: pointer;
-}
-
-/* --- Textarea --- */
-.modTools textarea {
- min-height: 100px;
- resize: vertical;
- font-family: var(--mt-font-body);
- font-size: 14px;
- line-height: var(--mt-leading-relaxed);
-}
-
-/* ==========================================================================
- 5. LAYOUT PATTERNS
- ========================================================================== */
-
-/* --- Input Row (legacy, stacked) --- */
-.modTools .inputRow {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-md);
- margin-bottom: var(--mt-space-lg);
-}
-
-.modTools .inputRow input,
-.modTools .inputRow select {
- margin-bottom: 0;
-}
-
-/* Search row - Input + Button inline */
-.modTools .searchRow {
- display: flex;
- gap: var(--mt-space-md);
- align-items: flex-start;
- margin-bottom: var(--mt-space-lg);
-}
-
-.modTools .searchRow input {
- flex: 1;
- margin-bottom: 0;
-}
-
-.modTools .searchRow .modtoolsButton {
- flex-shrink: 0;
- white-space: nowrap;
- /* Standard button sizing - not stretched */
- padding: 6px 14px;
- min-width: auto;
- width: auto;
-}
-
-/* Filter row - label inline with dropdown */
-.modTools .filterRow {
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
- margin-bottom: var(--mt-space-lg);
-}
-
-.modTools .filterRow label {
- margin-bottom: 0;
- white-space: nowrap;
-}
-
-.modTools .filterRow select {
- margin-bottom: 0;
- max-width: 200px;
-}
-
-/* Clear search button - centered */
-.modTools .clearSearchRow {
- display: flex;
- justify-content: center;
- margin-bottom: var(--mt-space-lg);
-}
-
-/* Action button row - for primary action buttons */
-.modTools .actionRow {
- display: flex;
- gap: var(--mt-space-md);
- align-items: center;
- flex-wrap: wrap;
- margin-top: var(--mt-space-lg);
-}
-
-/* Separator before delete section */
-.modTools .deleteSectionSeparator {
- margin-top: var(--mt-space-xl);
- margin-bottom: var(--mt-space-md);
- border-top: 1px solid var(--mt-border);
-}
-
-/* Section intro text - for counts, descriptions */
-.modTools .sectionIntro {
- margin-bottom: var(--mt-space-md);
- font-size: 15px;
- font-weight: 500;
- color: var(--mt-text);
-}
-
-/* Subsection heading */
-.modTools .subsectionHeading {
- margin-top: var(--mt-space-lg);
- margin-bottom: var(--mt-space-md);
- font-size: 15px;
- font-weight: 500;
- color: var(--mt-text);
-}
-
-/* Option row - for single option with label */
-.modTools .optionRow {
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
- margin-bottom: var(--mt-space-md);
-}
-
-.modTools .optionRow label {
- margin-bottom: 0;
- white-space: nowrap;
- min-width: fit-content;
-}
-
-.modTools .optionRow select,
-.modTools .optionRow input {
- margin-bottom: 0;
- flex: 1;
- max-width: 300px;
-}
-
-/* Node list container - for scrollable lists */
-.modTools .nodeListContainer {
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-md);
- padding: var(--mt-space-md);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-bg-card);
-}
-
-/* Node item - for individual editable items */
-.modTools .nodeItem {
- margin-bottom: var(--mt-space-md);
- padding: var(--mt-space-md);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-sm);
- border: 1px solid var(--mt-border);
- transition: all var(--mt-transition);
-}
-
-.modTools .nodeItem:last-child {
- margin-bottom: 0;
-}
-
-.modTools .nodeItem.modified {
- background: var(--mt-warning-bg);
- border-color: var(--mt-warning-border);
-}
-
-.modTools .nodeItem .nodeGrid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: var(--mt-space-md);
-}
-
-.modTools .nodeItem .nodeMeta {
- margin-top: var(--mt-space-sm);
- font-size: 12px;
- color: var(--mt-text-muted);
-}
-
-.modTools .nodeItem .nodeSharedTitle {
- margin-bottom: var(--mt-space-sm);
- font-size: 13px;
- color: var(--mt-text-secondary);
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
-}
-
-/* Small label for form fields */
-.modTools .fieldLabel {
- font-size: 13px;
- color: var(--mt-text-secondary);
- margin-bottom: var(--mt-space-xs);
- font-weight: 500;
-}
-
-/* Validation error on input */
-.modTools input.hasError,
-.modTools select.hasError {
- border-color: var(--mt-error) !important;
- background: var(--mt-error-bg);
-}
-
-.modTools .validationHint {
- font-size: var(--mt-text-sm);
- color: var(--mt-error);
- margin-top: var(--mt-space-xs);
-}
-
-/* --- Two-column grid for related fields --- */
-.modTools .formGrid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: var(--mt-space-md);
-}
-
-.modTools .formGrid.fullWidth {
- grid-column: 1 / -1;
-}
-
-/* ==========================================================================
- 6. BUTTONS
- ========================================================================== */
-
-/* --- Primary Button --- */
-.modTools .modtoolsButton {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--mt-space-xs);
- padding: 6px 14px;
- background: var(--mt-primary);
- color: var(--mt-text-on-primary);
- font-family: var(--mt-font-body);
- font-size: 12px;
- font-weight: 600;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- transition: all var(--mt-transition);
- text-decoration: none;
- white-space: nowrap;
- text-align: center;
-}
-
-.modTools .modtoolsButton:hover {
- background: var(--mt-primary-hover);
- transform: translateY(-1px);
- box-shadow: var(--mt-shadow-sm);
-}
-
-.modTools .modtoolsButton:active {
- transform: translateY(0);
-}
-
-.modTools .modtoolsButton:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- transform: none;
-}
-
-/* Secondary button */
-.modTools .modtoolsButton.secondary {
- background: transparent;
- color: var(--mt-primary);
- border: 2px solid var(--mt-primary);
- padding: 6px 14px;
-}
-
-.modTools .modtoolsButton.secondary:hover {
- background: var(--mt-primary-light);
-}
-
-/* Danger button */
-.modTools .modtoolsButton.danger {
- background: var(--mt-error);
-}
-
-.modTools .modtoolsButton.danger:hover {
- background: #B91C1C;
-}
-
-/* Small button */
-.modTools .modtoolsButton.small {
- padding: 5px 10px;
- font-size: 12px;
-}
-
-/* Loading spinner in buttons */
-.modTools .loadingSpinner {
- display: inline-block;
- width: 16px;
- height: 16px;
- border: 2px solid currentColor;
- border-right-color: transparent;
- border-radius: 50%;
- animation: mt-spin 0.7s linear infinite;
-}
-
-@keyframes mt-spin {
- to { transform: rotate(360deg); }
-}
-
-/* Button row */
-.modTools .buttonRow {
- display: flex;
- gap: var(--mt-space-md);
- flex-wrap: wrap;
- margin-top: var(--mt-space-lg);
-}
-
-/* --- File Upload Zones --- */
-.modTools input[type="file"] {
- display: block;
- width: 100%;
- padding: var(--mt-space-md);
- margin-bottom: var(--mt-space-sm);
- background: var(--mt-bg-subtle);
- border: 2px dashed var(--mt-border);
- border-radius: var(--mt-radius-sm);
- cursor: pointer;
- font-family: var(--mt-font-body);
- font-size: 13px;
- color: var(--mt-text-secondary);
-}
-
-.modTools input[type="file"]:hover {
- border-color: var(--mt-primary);
- background: var(--mt-primary-light);
-}
-
-.modTools input[type="file"]::file-selector-button {
- padding: 6px 12px;
- margin-right: var(--mt-space-sm);
- background: var(--mt-primary);
- color: white;
- border: none;
- border-radius: var(--mt-radius-sm);
- font-family: var(--mt-font-body);
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- transition: background var(--mt-transition);
-}
-
-.modTools input[type="file"]::file-selector-button:hover {
- background: var(--mt-primary-hover);
-}
-
-/* ==========================================================================
- 7. DATA DISPLAY COMPONENTS
- ========================================================================== */
-
-/* --- Index Selector --- */
-.modTools .indexSelectorContainer {
- margin-top: var(--mt-space-lg);
- background: var(--mt-bg-card);
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-lg);
- overflow: hidden;
-}
-
-/* Header */
-.modTools .indexSelectorHeader {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: var(--mt-space-md) var(--mt-space-lg);
- background: var(--mt-bg-subtle);
- border-bottom: 1px solid var(--mt-border);
- flex-wrap: wrap;
- gap: var(--mt-space-md);
-}
-
-.modTools .indexSelectorTitle {
- font-size: 16px;
- font-weight: 500;
- color: var(--mt-text);
-}
-
-.modTools .indexSelectorTitle .highlight {
- color: var(--mt-primary);
- font-weight: 700;
-}
-
-.modTools .indexSelectorActions {
- display: flex;
- align-items: center;
- gap: var(--mt-space-lg);
-}
-
-.modTools .selectionCount {
- font-size: 14px;
- color: var(--mt-text-secondary);
-}
-
-.modTools .selectAllToggle {
- display: flex;
- align-items: center;
- gap: var(--mt-space-sm);
- font-size: 14px;
- font-weight: 500;
- color: var(--mt-text);
- cursor: pointer;
- margin-bottom: 0;
-}
-
-.modTools .selectAllToggle input[type="checkbox"] {
- width: 18px;
- height: 18px;
- accent-color: var(--mt-primary);
- cursor: pointer;
-}
-
-/* Search input */
-.modTools .indexSearchWrapper {
- position: relative;
- padding: var(--mt-space-md) var(--mt-space-lg);
- background: var(--mt-bg-card);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .indexSearchInput {
- width: 100%;
- padding: 10px 40px 10px 16px;
- margin: 0;
- font-size: 14px;
- border: 1.5px solid var(--mt-border);
- border-radius: var(--mt-radius-md);
- background: var(--mt-bg-input);
-}
-
-.modTools .indexSearchInput:focus {
- outline: none;
- border-color: var(--mt-primary);
- box-shadow: 0 0 0 3px var(--mt-primary-light);
-}
-
-.modTools .indexSearchClear {
- position: absolute;
- right: calc(var(--mt-space-lg) + 12px);
- top: 50%;
- transform: translateY(-50%);
- width: 24px;
- height: 24px;
- border: none;
- background: transparent;
- color: var(--mt-text-muted);
- font-size: 18px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
-}
-
-.modTools .indexSearchClear:hover {
- background: var(--mt-bg-subtle);
- color: var(--mt-text);
-}
-
-/* Index List */
-.modTools .indexList {
- display: flex;
- flex-direction: column;
- max-height: 400px;
- overflow-y: auto;
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-md);
- background: var(--mt-bg-card);
-}
-
-/* Custom scrollbar for index list */
-.modTools .indexList::-webkit-scrollbar {
- width: 8px;
-}
-
-.modTools .indexList::-webkit-scrollbar-track {
- background: var(--mt-bg-subtle);
- border-radius: 4px;
-}
-
-.modTools .indexList::-webkit-scrollbar-thumb {
- background: var(--mt-border);
- border-radius: 4px;
-}
-
-.modTools .indexList::-webkit-scrollbar-thumb:hover {
- background: #B5B3AC;
-}
-
-/* Index List Row */
-.modTools .indexListRow {
- display: flex;
- align-items: center;
- gap: var(--mt-space-md);
- padding: var(--mt-space-sm) var(--mt-space-md);
- border-bottom: 1px solid var(--mt-border);
- cursor: pointer;
- transition: background var(--mt-transition);
-}
-
-.modTools .indexListRow:last-child {
- border-bottom: none;
-}
-
-.modTools .indexListRow:hover {
- background: var(--mt-primary-light);
-}
-
-.modTools .indexListRow.selected {
- background: rgba(24, 52, 93, 0.08);
- box-shadow: inset 3px 0 0 var(--mt-primary);
-}
-
-.modTools .indexListRow input[type="checkbox"] {
- flex-shrink: 0;
- width: 16px;
- height: 16px;
- accent-color: var(--mt-primary);
- cursor: pointer;
-}
-
-.modTools .indexListTitle {
- flex: 1;
- font-size: 14px;
- font-weight: 500;
- color: var(--mt-text);
- line-height: 1.4;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.modTools .indexListCategory {
- flex-shrink: 0;
- font-size: 12px;
- color: var(--mt-text-muted);
- padding: 2px 8px;
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-sm);
-}
-
-/* No results in search */
-.modTools .indexNoResults {
- text-align: center;
- padding: var(--mt-space-xl);
- color: var(--mt-text-muted);
- font-size: 14px;
-}
-
-/* Legacy indices list (fallback) */
-.modTools .selectionControls {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: var(--mt-space-md);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-md) var(--mt-radius-md) 0 0;
- border: 1px solid var(--mt-border);
- border-bottom: none;
-}
-
-.modTools .selectionButtons {
- display: flex;
- gap: var(--mt-space-sm);
-}
-
-.modTools .indicesList {
- max-height: 280px;
- overflow-y: auto;
- border: 1px solid var(--mt-border);
- border-radius: 0 0 var(--mt-radius-md) var(--mt-radius-md);
- padding: var(--mt-space-sm);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-bg-card);
- scroll-behavior: smooth;
-}
-
-.modTools .indicesList label {
- display: flex;
- align-items: center;
- padding: var(--mt-space-sm) var(--mt-space-md);
- margin: 2px 0;
- cursor: pointer;
- border-radius: var(--mt-radius-sm);
- transition: background var(--mt-transition);
- font-weight: 400;
- font-size: 14px;
-}
-
-.modTools .indicesList label:hover {
- background: var(--mt-bg-subtle);
-}
-
-.modTools .indicesList label.selected {
- background: var(--mt-primary-light);
- border-left: 3px solid var(--mt-primary);
-}
-
-.modTools .indicesList label input[type="checkbox"] {
- width: 18px;
- height: 18px;
- margin: 0 var(--mt-space-md) 0 0;
-}
-
-/* ==========================================================================
- 8. FIELD GROUPS
- ========================================================================== */
-.modTools .fieldGroup {
- margin-bottom: var(--mt-space-sm);
-}
-
-.modTools .fieldGroup label {
- margin-bottom: var(--mt-space-xs);
-}
-
-.modTools .fieldGroup .fieldInput {
- margin-bottom: var(--mt-space-xs);
-}
-
-.modTools .fieldGroup .fieldInput:disabled {
- background-color: #f5f5f5;
- color: #999;
- cursor: not-allowed;
- opacity: 0.6;
-}
-
-.modTools .fieldHelp {
- font-size: 13px;
- color: var(--mt-text-muted);
- margin-bottom: var(--mt-space-sm);
- line-height: 1.5;
-}
-
-/* Field group sections */
-.modTools .fieldGroupSection {
- margin-bottom: var(--mt-space-md);
- padding: var(--mt-space-sm) var(--mt-space-md);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-sm);
- border: 1px solid var(--mt-border);
-}
-
-.modTools .fieldGroupHeader {
- font-family: var(--mt-font-body);
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 1px;
- color: var(--mt-text-muted);
- margin-bottom: var(--mt-space-md);
- padding-bottom: var(--mt-space-sm);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .fieldGroupGrid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: var(--mt-space-lg) var(--mt-space-md);
- align-items: end; /* Align inputs at bottom of each row */
-}
-
-.modTools .fieldGroupGrid .fieldGroup {
- margin-bottom: 0;
-}
-
-.modTools .fieldGroup.fullWidth {
- grid-column: 1 / -1;
-}
-
-/* Field name badge */
-.modTools .fieldNameBadge {
- display: inline-block;
- font-family: var(--mt-font-mono);
- font-size: 11px;
- padding: 2px 6px;
- background: var(--mt-bg-subtle);
- border: 1px solid var(--mt-border);
- border-radius: 4px;
- color: var(--mt-text-muted);
- margin-left: var(--mt-space-sm);
- vertical-align: middle;
-}
-
-/* Validation states */
-.modTools .fieldGroup.hasError input,
-.modTools .fieldGroup.hasError select {
- border-color: var(--mt-error);
- background: var(--mt-error-bg);
-}
-
-.modTools .fieldError {
- font-size: var(--mt-text-sm);
- color: var(--mt-error);
- margin-top: var(--mt-space-xs);
-}
-
-.modTools .requiredIndicator {
- color: var(--mt-error);
- font-weight: bold;
- margin-left: 2px;
-}
-
-/* ==========================================================================
- 9. FEEDBACK & ALERTS
- ========================================================================== */
-.modTools .message {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-top: var(--mt-space-md);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
-}
-
-.modTools .message.success {
- background: var(--mt-success-bg);
- color: var(--mt-success);
- border: 1px solid var(--mt-success-border);
-}
-
-.modTools .message.warning {
- background: var(--mt-warning-bg);
- color: var(--mt-warning-text);
- border: 1px solid var(--mt-warning-border);
-}
-
-.modTools .message.error {
- background: var(--mt-error-bg);
- color: var(--mt-error);
- border: 1px solid var(--mt-error-border);
-}
-
-.modTools .message.info {
- background: var(--mt-info-bg);
- color: #0E7490;
- border: 1px solid var(--mt-info-border);
-}
-
-/* Info/Warning/Danger boxes */
-.modTools .infoBox {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-info-bg);
- border: 1px solid var(--mt-info-border);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
- color: #0E7490;
-}
-
-.modTools .infoBox strong {
- color: var(--mt-info);
-}
-
-.modTools .warningBox {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-warning-bg);
- border: 1px solid var(--mt-warning-border);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
- color: #92400E;
-}
-
-.modTools .warningBox strong {
- display: block;
- margin-bottom: var(--mt-space-sm);
- color: var(--mt-warning);
- font-size: 15px;
-}
-
-.modTools .warningBox ul {
- margin: var(--mt-space-sm) 0 0;
- padding-left: var(--mt-space-lg);
-}
-
-.modTools .warningBox li {
- margin-bottom: var(--mt-space-sm);
-}
-
-.modTools .warningBox li:last-child {
- margin-bottom: 0;
-}
-
-.modTools .warningBox li strong {
- display: inline;
- margin-bottom: 0;
-}
-
-.modTools .warningBox li p {
- margin: var(--mt-space-xs) 0 0;
-}
-
-.modTools .dangerBox {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: var(--mt-error-bg);
- border: 1px solid var(--mt-error-border);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
- line-height: 1.6;
- color: #991B1B;
-}
-
-.modTools .dangerBox strong {
- color: var(--mt-error);
-}
-
-/* Changes preview box */
-.modTools .changesPreview {
- padding: var(--mt-space-md) var(--mt-space-lg);
- margin-bottom: var(--mt-space-lg);
- background: linear-gradient(135deg, rgba(72, 113, 191, 0.08) 0%, rgba(24, 52, 93, 0.06) 100%);
- border: 1px solid rgba(72, 113, 191, 0.3);
- border-radius: var(--mt-radius-md);
- font-size: 14px;
-}
-
-.modTools .changesPreview strong {
- display: block;
- margin-bottom: var(--mt-space-sm);
- color: var(--mt-primary);
-}
-
-.modTools .changesPreview ul {
- margin: var(--mt-space-xs) 0 0 var(--mt-space-lg);
- padding: 0;
-}
-
-.modTools .changesPreview li {
- margin-bottom: var(--mt-space-xs);
- color: var(--mt-text-secondary);
-}
-
-/* No results state */
-.modTools .noResults {
- padding: var(--mt-space-xl);
- text-align: center;
- color: var(--mt-text-muted);
- background: var(--mt-bg-subtle);
- border-radius: var(--mt-radius-md);
- margin-top: var(--mt-space-md);
-}
-
-.modTools .noResults strong {
- display: block;
- color: var(--mt-text);
- margin-bottom: var(--mt-space-sm);
- font-size: 16px;
-}
-
-/* ==========================================================================
- Workflowy & Legacy Forms
- ========================================================================== */
-.modTools .workflowy-tool {
- width: 100%;
-}
-
-.modTools .workflowy-tool .workflowy-tool-form {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-md);
-}
-
-.modTools .workflowy-tool textarea {
- width: 100%;
- min-height: 200px;
- font-family: var(--mt-font-mono);
- font-size: 12px;
-}
-
-.modTools .getLinks,
-.modTools .uploadLinksFromCSV,
-.modTools .remove-links-csv {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-xs);
-}
-
-.modTools .getLinks form,
-.modTools .uploadLinksFromCSV form,
-.modTools .remove-links-csv form {
- display: flex;
- flex-direction: column;
- gap: var(--mt-space-xs);
-}
-
-.modTools .getLinks fieldset {
- border: 1px solid var(--mt-border);
- border-radius: var(--mt-radius-sm);
- padding: var(--mt-space-sm) var(--mt-space-md);
- margin: 0;
- background: var(--mt-bg-subtle);
-}
-
-.modTools .getLinks fieldset legend {
- padding: 0 var(--mt-space-xs);
- font-weight: 600;
- font-size: 12px;
-}
-
-/* Submit buttons in legacy forms */
-.modTools input[type="submit"] {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 6px 14px;
- background: var(--mt-primary);
- color: var(--mt-text-on-primary);
- font-family: var(--mt-font-body);
- font-size: 12px;
- font-weight: 600;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- transition: all var(--mt-transition);
- align-self: flex-start;
-}
-
-.modTools input[type="submit"]:hover {
- background: var(--mt-primary-hover);
-}
-
-.modTools input[type="submit"]:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-/* ==========================================================================
- Checkbox Styling
- ========================================================================== */
-.modTools input[type="checkbox"] {
- width: 14px;
- height: 14px;
- margin: 0;
- cursor: pointer;
- accent-color: var(--mt-primary);
-}
-
-.modTools .checkboxLabel {
- display: inline-flex;
- align-items: center;
- gap: var(--mt-space-xs);
- cursor: pointer;
- padding: 0;
- font-weight: 400;
- font-size: 13px;
-}
-
-.modTools label:has(input[type="checkbox"]) {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: var(--mt-space-xs);
- font-weight: 400;
- cursor: pointer;
- padding: var(--mt-space-xs) 0;
- font-size: 13px;
-}
-
-/* ==========================================================================
- 10. COLLAPSIBLE SECTIONS
- ========================================================================== */
-
-/* Section header - clickable to toggle */
-.modTools .sectionHeader {
- display: flex;
- align-items: center;
- justify-content: space-between;
- cursor: pointer;
- user-select: none;
- padding-bottom: var(--mt-space-md);
- margin-bottom: var(--mt-space-md);
- border-bottom: var(--mt-border-width-heavy) solid var(--mt-border);
- transition: all var(--mt-transition);
-}
-
-.modTools .sectionHeader:hover {
- border-bottom-color: var(--mt-primary);
-}
-
-.modTools .sectionHeader:hover .dlSectionTitle {
- color: var(--mt-primary-hover);
-}
-
-/* When section header exists, title shouldn't have its own border */
-.modTools .sectionHeader .dlSectionTitle {
- margin: 0;
- padding: 0; /* Reset legacy padding from s2.css */
- border-bottom: none;
- white-space: nowrap;
- line-height: 1;
-}
-
-/* Left side: collapse toggle + title */
-.modTools .sectionHeaderLeft {
- display: flex;
- align-items: center;
- gap: var(--mt-space-sm);
-}
-
-/* Right side: help button */
-.modTools .sectionHeaderRight {
- flex-shrink: 0;
-}
-
-/* Collapse toggle indicator - on the left */
-.modTools .collapseToggle {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: var(--mt-bg-subtle);
- border: 1.5px solid var(--mt-border);
- color: var(--mt-text-secondary);
- font-size: 14px;
- transition: all var(--mt-transition);
- flex-shrink: 0;
-}
-
-.modTools .sectionHeader:hover .collapseToggle {
- background: var(--mt-primary-light);
- border-color: var(--mt-primary);
- color: var(--mt-primary);
-}
-
-.modTools .collapseToggle img {
- width: 14px;
- height: 14px;
- transition: transform var(--mt-transition);
-}
-
-/* Collapsed state */
-.modTools .modToolsSection.collapsed .collapseToggle img {
- transform: rotate(-90deg);
-}
-
-.modTools .modToolsSection.collapsed .sectionHeader {
- margin-bottom: 0;
- padding-bottom: var(--mt-space-md);
-}
-
-/* Section content - animated collapse */
-.modTools .sectionContent {
- overflow: hidden;
- transition: max-height 0.3s ease-out, opacity 0.2s ease-out;
- max-height: 5000px; /* Large enough for any content */
- opacity: 1;
-}
-
-.modTools .modToolsSection.collapsed .sectionContent {
- max-height: 0;
- opacity: 0;
- pointer-events: none;
-}
-
-/* ==========================================================================
- 11. HELP BUTTON & MODAL
- ========================================================================== */
-
-/* Help Button - in section header actions */
-.modTools .helpButton {
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: var(--mt-bg-subtle);
- border: 1.5px solid var(--mt-border);
- color: var(--mt-text-secondary);
- font-size: 16px;
- font-weight: 600;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all var(--mt-transition);
- font-family: var(--mt-font-body);
- line-height: 1;
- flex-shrink: 0;
-}
-
-.modTools .helpButton:hover {
- background: var(--mt-primary);
- border-color: var(--mt-primary);
- color: var(--mt-text-on-primary);
- transform: scale(1.05);
-}
-
-.modTools .helpButton:focus {
- outline: none;
- box-shadow: 0 0 0 3px var(--mt-primary-light);
-}
-
-/* Modal Overlay */
-.modTools .helpModal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10000;
- padding: var(--mt-space-lg);
- animation: mt-fadeIn 0.15s ease-out;
-}
-
-@keyframes mt-fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-/* Modal Container */
-.modTools .helpModal {
- background: var(--mt-bg-card);
- border-radius: var(--mt-radius-lg);
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
- max-width: 680px;
- width: 100%;
- max-height: 85vh;
- display: flex;
- flex-direction: column;
- animation: mt-slideUp 0.2s ease-out;
-}
-
-@keyframes mt-slideUp {
- from {
- opacity: 0;
- transform: translateY(20px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* Modal Header */
-.modTools .helpModal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--mt-space-lg) var(--mt-space-xl);
- border-bottom: 1px solid var(--mt-border);
- flex-shrink: 0;
-}
-
-.modTools .helpModal-title {
- font-family: var(--mt-font-display);
- font-size: var(--mt-text-2xl);
- font-weight: var(--mt-font-semibold);
- color: var(--mt-primary);
- margin: 0;
- line-height: 1.3;
-}
-
-.modTools .helpModal-close {
- width: 36px;
- height: 36px;
- border-radius: 50%;
- border: none;
- background: var(--mt-bg-subtle);
- color: var(--mt-text-secondary);
- font-size: 24px;
- line-height: 1;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all var(--mt-transition);
-}
-
-.modTools .helpModal-close:hover {
- background: var(--mt-error-bg);
- color: var(--mt-error);
-}
-
-/* Modal Body - Scrollable */
-.modTools .helpModal-body {
- padding: var(--mt-space-xl);
- overflow-y: auto;
- flex: 1;
- font-size: 15px;
- line-height: 1.7;
- color: var(--mt-text);
-}
-
-/* Content styling within modal */
-.modTools .helpModal-body h3 {
- font-family: var(--mt-font-body);
- font-size: 16px;
- font-weight: 600;
- color: var(--mt-primary);
- margin: var(--mt-space-lg) 0 var(--mt-space-md) 0;
- padding-bottom: var(--mt-space-sm);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .helpModal-body h3:first-child {
- margin-top: 0;
-}
-
-.modTools .helpModal-body p {
- margin: 0 0 var(--mt-space-md) 0;
-}
-
-.modTools .helpModal-body ul,
-.modTools .helpModal-body ol {
- margin: 0 0 var(--mt-space-md) 0;
- padding-left: var(--mt-space-xl);
-}
-
-.modTools .helpModal-body li {
- margin-bottom: var(--mt-space-sm);
-}
-
-.modTools .helpModal-body li:last-child {
- margin-bottom: 0;
-}
-
-.modTools .helpModal-body strong {
- font-weight: 600;
- color: var(--mt-text);
-}
-
-.modTools .helpModal-body code {
- font-family: var(--mt-font-mono);
- font-size: 13px;
- background: var(--mt-bg-subtle);
- padding: 2px 6px;
- border-radius: 4px;
- color: var(--mt-primary);
-}
-
-.modTools .helpModal-body .warning {
- background: var(--mt-warning-bg);
- border: 1px solid var(--mt-warning-border);
- border-radius: var(--mt-radius-md);
- padding: var(--mt-space-md);
- margin: var(--mt-space-md) 0;
- color: var(--mt-warning-text);
-}
-
-.modTools .helpModal-body .warning strong {
- color: var(--mt-warning);
-}
-
-.modTools .helpModal-body .info {
- background: var(--mt-info-bg);
- border: 1px solid var(--mt-info-border);
- border-radius: var(--mt-radius-md);
- padding: var(--mt-space-md);
- margin: var(--mt-space-md) 0;
- color: var(--mt-info-text);
-}
-
-.modTools .helpModal-body .field-table {
- width: 100%;
- border-collapse: collapse;
- margin: var(--mt-space-md) 0;
- font-size: 14px;
-}
-
-.modTools .helpModal-body .field-table th,
-.modTools .helpModal-body .field-table td {
- text-align: left;
- padding: var(--mt-space-sm) var(--mt-space-md);
- border-bottom: 1px solid var(--mt-border);
-}
-
-.modTools .helpModal-body .field-table th {
- background: var(--mt-bg-subtle);
- font-weight: 600;
- color: var(--mt-text);
-}
-
-.modTools .helpModal-body .field-table tr:last-child td {
- border-bottom: none;
-}
-
-/* Modal Footer */
-.modTools .helpModal-footer {
- padding: var(--mt-space-md) var(--mt-space-xl);
- border-top: 1px solid var(--mt-border);
- display: flex;
- justify-content: flex-end;
- flex-shrink: 0;
-}
-
-/* ==========================================================================
- Print Styles
- ========================================================================== */
-@media print {
- .modTools {
- background: white;
- }
-
- .modTools::before {
- display: none;
- }
-
- .modTools .modToolsSection {
- box-shadow: none;
- border: 1px solid #ddd;
- break-inside: avoid;
- }
-
- .modTools .modToolsSection.collapsed .sectionContent {
- max-height: none;
- opacity: 1;
- }
-
- .modTools .modtoolsButton {
- display: none;
- }
-
- .modTools .helpButton,
- .modTools .collapseToggle {
- display: none;
- }
-
- .modTools .helpModal-overlay {
- display: none;
- }
-}
diff --git a/static/js/ModeratorToolsPanel.jsx b/static/js/ModeratorToolsPanel.jsx
index ce09dc28bf..6b8e38cdca 100644
--- a/static/js/ModeratorToolsPanel.jsx
+++ b/static/js/ModeratorToolsPanel.jsx
@@ -18,7 +18,7 @@
* - See /docs/modtools/MODTOOLS_GUIDE.md for quick reference
* - See /docs/modtools/COMPONENT_LOGIC.md for implementation details
*
- * CSS: Styles are in /static/css/static.css (search for "ModTools Design System")
+ * CSS: Styles are in /static/css/s2.css (search for "ModTools Design System")
*/
import Sefaria from './sefaria/sefaria';
From 2f23c3946da73d3516e6282a46316257d06a83e4 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Tue, 20 Jan 2026 12:31:57 +0200
Subject: [PATCH 25/26] refactor: remove duplicate stripHtmlTags, update docs
for s2.css
- Remove stripHtmlTags from shared/index.js (use String.prototype.stripHtml() instead)
- Delete stripHtmlTags.test.js (testing built-in method not needed)
- Update MODTOOLS_GUIDE.md to reference s2.css instead of modtools.css
Co-Authored-By: Claude Opus 4.5
---
docs/modtools/MODTOOLS_GUIDE.md | 8 +-
static/js/modtools/components/shared/index.js | 13 ---
.../js/modtools/tests/stripHtmlTags.test.js | 108 ------------------
3 files changed, 4 insertions(+), 125 deletions(-)
delete mode 100644 static/js/modtools/tests/stripHtmlTags.test.js
diff --git a/docs/modtools/MODTOOLS_GUIDE.md b/docs/modtools/MODTOOLS_GUIDE.md
index 3bb53bbed5..3b8eb31ff6 100644
--- a/docs/modtools/MODTOOLS_GUIDE.md
+++ b/docs/modtools/MODTOOLS_GUIDE.md
@@ -24,7 +24,7 @@ ModTools is an internal admin interface at `/modtools` for Sefaria staff. Access
| `static/js/modtools/components/*.jsx` | Individual tools | Editing tool behavior |
| `static/js/modtools/components/shared/*.jsx` | Shared components | Editing shared UI |
| `static/js/modtools/constants/fieldMetadata.js` | Field definitions | Adding/editing fields |
-| `static/css/modtools.css` | All styles | UI changes |
+| `static/css/s2.css` | All styles | UI changes |
| `sefaria/views.py` | Backend APIs | API changes |
---
@@ -49,7 +49,7 @@ static/js/
└── StatusMessage.jsx # Message display
static/css/
-└── modtools.css # All modtools styles
+└── s2.css # All modtools styles
sefaria/
├── views.py # Backend API handlers
@@ -232,7 +232,7 @@ const FIELD_GROUPS = [
1. Create component: `static/js/modtools/components/NewTool.jsx`
2. Export from: `static/js/modtools/index.js`
3. Import in: `ModeratorToolsPanel.jsx`
-4. Add CSS if needed: `modtools.css`
+4. Add CSS if needed: `s2.css`
**Template**:
```jsx
@@ -312,7 +312,7 @@ if (value === 'false') value = false;
## CSS Classes
-Key classes in `modtools.css`:
+Key classes in `s2.css`:
| Class | Purpose |
|-------|---------|
diff --git a/static/js/modtools/components/shared/index.js b/static/js/modtools/components/shared/index.js
index f979d36ee2..b1845c253d 100644
--- a/static/js/modtools/components/shared/index.js
+++ b/static/js/modtools/components/shared/index.js
@@ -7,16 +7,3 @@ export { default as ModToolsSection } from './ModToolsSection';
export { default as StatusMessage } from './StatusMessage';
export { default as IndexSelector } from './IndexSelector';
export { default as HelpButton } from './HelpButton';
-
-// Utility function for safe HTML text extraction (re-exported for convenience)
-export const stripHtmlTags = (text) => {
- if (!text) return '';
- return text
- .replace(/<[^>]*>/g, '')
- .replace(/ /g, ' ')
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .trim();
-};
diff --git a/static/js/modtools/tests/stripHtmlTags.test.js b/static/js/modtools/tests/stripHtmlTags.test.js
deleted file mode 100644
index b8226b8c06..0000000000
--- a/static/js/modtools/tests/stripHtmlTags.test.js
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * Tests for stripHtmlTags utility function
- *
- * This function is security-critical for XSS prevention.
- * It safely extracts text content from HTML strings.
- */
-import { stripHtmlTags } from '../components/shared';
-
-describe('stripHtmlTags', () => {
- describe('basic HTML removal', () => {
- it('removes simple HTML tags', () => {
- expect(stripHtmlTags('Hello
')).toBe('Hello');
- });
-
- it('removes nested HTML tags', () => {
- expect(stripHtmlTags('')).toBe('Nested');
- });
-
- it('removes self-closing tags', () => {
- expect(stripHtmlTags('Hello World')).toBe('HelloWorld');
- });
-
- it('removes tags with attributes', () => {
- expect(stripHtmlTags('Link ')).toBe('Link');
- });
-
- it('removes script tags', () => {
- expect(stripHtmlTags('Safe')).toBe('alert("xss")Safe');
- });
-
- it('removes style tags', () => {
- expect(stripHtmlTags('Text')).toBe('.red{color:red}Text');
- });
- });
-
- describe('HTML entity decoding', () => {
- it('decodes to space', () => {
- expect(stripHtmlTags('Hello World')).toBe('Hello World');
- });
-
- it('decodes & to ampersand', () => {
- expect(stripHtmlTags('Tom & Jerry')).toBe('Tom & Jerry');
- });
-
- it('decodes < to less-than', () => {
- expect(stripHtmlTags('5 < 10')).toBe('5 < 10');
- });
-
- it('decodes > to greater-than', () => {
- expect(stripHtmlTags('10 > 5')).toBe('10 > 5');
- });
-
- it('decodes " to quote', () => {
- expect(stripHtmlTags('He said "hello"')).toBe('He said "hello"');
- });
-
- it('decodes multiple entities in one string', () => {
- expect(stripHtmlTags('<div>& "test"')).toBe('& "test"');
- });
- });
-
- describe('edge cases', () => {
- it('returns empty string for null', () => {
- expect(stripHtmlTags(null)).toBe('');
- });
-
- it('returns empty string for undefined', () => {
- expect(stripHtmlTags(undefined)).toBe('');
- });
-
- it('returns empty string for empty string', () => {
- expect(stripHtmlTags('')).toBe('');
- });
-
- it('trims whitespace', () => {
- expect(stripHtmlTags(' hello ')).toBe('hello');
- });
-
- it('handles plain text without HTML', () => {
- expect(stripHtmlTags('Just plain text')).toBe('Just plain text');
- });
-
- it('handles malformed HTML', () => {
- expect(stripHtmlTags('
Unclosed paragraph')).toBe('Unclosed paragraph');
- });
-
- it('handles empty tags', () => {
- expect(stripHtmlTags('
')).toBe('');
- });
- });
-
- describe('real-world error messages', () => {
- it('strips HTML from typical error response', () => {
- const errorHtml = '
Error: Invalid reference
';
- expect(stripHtmlTags(errorHtml)).toBe('Error: Invalid reference');
- });
-
- it('handles multi-line HTML content', () => {
- const multiLine = `
`;
- const result = stripHtmlTags(multiLine);
- expect(result).toContain('Error 1');
- expect(result).toContain('Error 2');
- });
- });
-});
From 94c9ad7b3a0d8473f186578f81670ae2910a4c86 Mon Sep 17 00:00:00 2001
From: dcschreiber
Date: Thu, 19 Mar 2026 17:05:46 +0200
Subject: [PATCH 26/26] fix(modtools): improve help text for versionTitle and
mark-for-deletion
Clarify that versionTitle is a database identifier and cannot be bulk
edited. Improve mark-for-deletion messaging to explain the two-step
process: marking adds a note, then a developer completes the deletion.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
static/js/modtools/components/BulkVersionEditor.jsx | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/static/js/modtools/components/BulkVersionEditor.jsx b/static/js/modtools/components/BulkVersionEditor.jsx
index 9f300042f2..cee23dbdfc 100644
--- a/static/js/modtools/components/BulkVersionEditor.jsx
+++ b/static/js/modtools/components/BulkVersionEditor.jsx
@@ -51,8 +51,8 @@ const HELP_CONTENT = (
Available Fields
- Note: The version title you searched for is used to identify which versions to update.
- To rename a version, edit it individually (not in bulk).
+ Note: The versionTitle field serves as a database identifier for versions
+ and cannot be edited in bulk. To rename a version title, edit versions individually.
@@ -92,9 +92,13 @@ const HELP_CONTENT = (
Mark for Deletion
The "Mark for Deletion" button does NOT immediately delete versions. Instead, it adds
- a timestamped note to versionNotes flagging the version for manual review.
+ a timestamped "[MARKED FOR DELETION]" note to versionNotes, flagging them for review.
This is a safety mechanism to prevent accidental data loss.
+
+ To complete the deletion after marking, contact a developer who can query the database
+ for versions with this note and remove them after verification.
+
Important Notes:
@@ -406,7 +410,7 @@ const BulkVersionEditor = () => {
await performBulkEdit(
{ versionNotes: deletionNote },
- (successCount) => `Marked ${successCount} versions for deletion review. They can be found by searching for "[MARKED FOR DELETION" in version notes.`,
+ (successCount) => `Marked ${successCount} versions for deletion review. A timestamped "[MARKED FOR DELETION]" note has been added to their versionNotes. To complete the deletion, contact a developer to query for and remove these versions from the database.`,
(successCount, total, failureList) => `Marked ${successCount}/${total} versions.\n\nFailed:\n${failureList}`,
(failureCount, failureList) => `All ${failureCount} versions failed to be marked for deletion:\n${failureList}`
);