diff --git a/ambuda/templates/proofing/projects/edit.html b/ambuda/templates/proofing/projects/edit.html
index 71fbe0b8..988a18b7 100644
--- a/ambuda/templates/proofing/projects/edit.html
+++ b/ambuda/templates/proofing/projects/edit.html
@@ -15,12 +15,14 @@
{{ m.project_nav(project=project, active='edit') }}
{% set search_url = url_for("proofing.project.search", slug=project.slug) %}
+{% set replace_url = url_for("proofing.project.replace", slug=project.slug) %}
{% set ocr_url = url_for("proofing.project.batch_ocr", slug=project.slug) %}
diff --git a/ambuda/templates/proofing/projects/replace.html b/ambuda/templates/proofing/projects/replace.html
new file mode 100644
index 00000000..3bbb8f98
--- /dev/null
+++ b/ambuda/templates/proofing/projects/replace.html
@@ -0,0 +1,47 @@
+{% extends 'proofing/base.html' %}
+{% from "macros/forms.html" import field %}
+{% import "macros/proofing.html" as m %}
+
+{% block title %}Search and Replace {{ project.title }} | Ambuda{% endblock %}
+
+{% block content %}
+
+{{ m.project_header_nested('Search and Replace', project) }}
+{{ m.project_nav(project=project, active='edit') }}
+
+
+
Use this simple search and replace form to make edits across this project.
+
+
+
+
+{% if query %}
+
+
+{% macro sp(s, p, n) %}{% if n == 1 %}{{ s }}{% else %}{{ p }}{% endif %}{% endmacro %}
+
+{% set nr = results|length %}
+
Found {{ nr }} {{ sp("page", "pages", nr) }} that {{ sp("contains", "contain", nr) }} {{ query }}.
+
+
+{% for page in results %}
+{% set page_url = url_for("proofing.page.edit", project_slug=project.slug, page_slug=page.slug) %}
+-
+ {{ project.title }}/{{ page.slug }}
+
+ {% for match in page.matches %}
+
{{ match.query }}
+
{{ match.update }}
+ {%- endfor %}
+
+
+{% endfor %}
+
+
+
+{% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/ambuda/views/proofing/project.py b/ambuda/views/proofing/project.py
index 6e7f554b..9a375790 100644
--- a/ambuda/views/proofing/project.py
+++ b/ambuda/views/proofing/project.py
@@ -96,6 +96,13 @@ class DeleteProjectForm(FlaskForm):
slug = StringField("Slug", validators=[DataRequired()])
+class ReplaceForm(SearchForm):
+ class Meta:
+ csrf = False
+
+ replace = StringField(_l("Replace"), validators=[DataRequired()])
+
+
@bp.route("/
/")
def summary(slug):
"""Show basic information about the project."""
@@ -279,6 +286,64 @@ def search(slug):
)
+@bp.route("//replace")
+@login_required
+def replace(slug):
+ """Search and replace a string across all of the project's pages.
+
+ This is useful to replace a string across the project in one shot.
+ """
+ project_ = q.project(slug)
+ if project_ is None:
+ abort(404)
+
+ form = ReplaceForm(request.args)
+ if not form.validate():
+ return render_template(
+ "proofing/projects/replace.html", project=project_, form=form
+ )
+
+ # search for "query" string and replace with "update" string
+ query = form.query.data
+ update = form.replace.data
+
+ results = []
+ for page_ in project_.pages:
+ if not page_.revisions:
+ continue
+
+ matches = []
+
+ latest = page_.revisions[-1]
+ for line in latest.content.splitlines():
+ if query in line:
+ matches.append(
+ {
+ "query": escape(line).replace(
+ query, Markup(f"{escape(query)}")
+ ),
+ "update": escape(line).replace(
+ query, Markup(f"{escape(update)}")
+ ),
+ }
+ )
+ if matches:
+ results.append(
+ {
+ "slug": page_.slug,
+ "matches": matches,
+ }
+ )
+ return render_template(
+ "proofing/projects/replace.html",
+ project=project_,
+ form=form,
+ query=query,
+ update=update,
+ results=results,
+ )
+
+
@bp.route("//batch-ocr", methods=["GET", "POST"])
@p2_required
def batch_ocr(slug):