Add live search progress, session counter, and fix URLs
- Search candidates client-side with JS, showing "Checking X..." spinner instead of leaving user waiting on a blank page - Fix broken api_valid_hit endpoint (get_diff returns dict, not tuple) - Remove server-side get_best_hit; article_page now returns candidate list immediately and JS iterates via /api/1/valid_hit - URL now reflects current article via history.replaceState (?title=X), Skip navigates to ?after=X to advance past it - Track saves in session; show count as green badge in navbar - Add session counter incremented on each successful save Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bc6265d4cd
commit
2c197f5c43
3 changed files with 114 additions and 71 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ title }} in {{ hit_title }}{% endblock %}
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
{% block style %}
|
{% block style %}
|
||||||
<link href="{{ url_for("static", filename="css/diff.css") }}" rel="stylesheet"/>
|
<link href="{{ url_for("static", filename="css/diff.css") }}" rel="stylesheet"/>
|
||||||
|
|
@ -16,31 +16,49 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap align-items-baseline gap-3 mb-1">
|
<div class="d-flex flex-wrap align-items-baseline gap-3 mb-1">
|
||||||
<h1 class="h4 mb-0">Link "{{ title }}" in "{{ hit_title }}"</h1>
|
<h1 class="h4 mb-0">Find links to "{{ title }}"</h1>
|
||||||
<a href="https://en.wikipedia.org/wiki/{{ title }}" target="_blank" class="text-muted small">{{ title }} ↗</a>
|
<a href="https://en.wikipedia.org/wiki/{{ title }}" target="_blank" class="text-muted small">{{ title }} ↗</a>
|
||||||
<a href="https://en.wikipedia.org/wiki/{{ hit_title }}" target="_blank" class="text-muted small">{{ hit_title }} ↗</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-3 mb-4 text-muted small">
|
<div class="d-flex gap-3 mb-3 text-muted small">
|
||||||
<span>{{ total }} mentions total</span>
|
<span>{{ total }} mentions total</span>
|
||||||
<span>{{ with_link }} already linked ({{ "{:.0%}".format(with_link / total) }})</span>
|
<span>{{ with_link }} already linked ({{ "{:.0%}".format(with_link / total) }})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div id="search-progress" class="my-4">
|
||||||
<table class="diff">{{ diff | safe }}</table>
|
<div class="d-flex align-items-center gap-2 text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span class="visually-hidden">Searching…</span>
|
||||||
|
</div>
|
||||||
|
<span id="search-status">Searching…</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="result" hidden>
|
||||||
|
<div class="d-flex flex-wrap align-items-baseline gap-2 mb-3">
|
||||||
|
<span class="text-muted small">Adding link in</span>
|
||||||
|
<a id="result-hit-link" href="#" target="_blank" class="small"><span id="result-hit-title"></span> ↗</a>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<table class="diff" id="diff-table"></table>
|
||||||
|
</div>
|
||||||
<form method="POST" class="mb-4">
|
<form method="POST" class="mb-4">
|
||||||
<input type="hidden" name="hit" value="{{ hit_title }}">
|
<input type="hidden" name="hit" id="hit-input">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-success">Save edit</button>
|
<button type="submit" class="btn btn-success">Save edit</button>
|
||||||
<a href="{{ url_for("article_page", url_title=url_title, after=hit_title) }}" class="btn btn-outline-secondary">Skip</a>
|
<a id="skip-link" href="#" class="btn btn-outline-secondary">Skip</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="all-done" hidden class="text-center mt-4">
|
||||||
|
<p class="text-muted mb-4">No more candidates found for this article.</p>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-primary">Search another article</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if hits %}
|
{% if hits %}
|
||||||
<details class="border rounded p-3">
|
<details class="border rounded p-3 mt-2">
|
||||||
<summary class="text-muted small" style="cursor:pointer">{{ hits | length }} other candidates</summary>
|
<summary class="text-muted small" style="cursor:pointer">{{ hits | length }} candidates</summary>
|
||||||
<ol class="mt-3 mb-0 small">
|
<ol class="mt-3 mb-0 small">
|
||||||
{% for hit in hits %}
|
{% for hit in hits %}
|
||||||
<li class="mb-1">
|
<li class="mb-1">
|
||||||
|
|
@ -52,3 +70,63 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const hits = {{ hits | map(attribute='title') | list | tojson }};
|
||||||
|
const linkTo = {{ title | tojson }};
|
||||||
|
const apiUrl = {{ url_for('api_valid_hit') | tojson }};
|
||||||
|
const pageUrl = new URL(window.location.href);
|
||||||
|
|
||||||
|
const elProgress = document.getElementById('search-progress');
|
||||||
|
const elStatus = document.getElementById('search-status');
|
||||||
|
const elResult = document.getElementById('result');
|
||||||
|
const elAllDone = document.getElementById('all-done');
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
for (const hitTitle of hits) {
|
||||||
|
elStatus.textContent = `Checking "${hitTitle}"…`;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ link_to: linkTo, link_from: hitTitle });
|
||||||
|
const resp = await fetch(apiUrl + '?' + params);
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.valid) continue;
|
||||||
|
|
||||||
|
elProgress.hidden = true;
|
||||||
|
|
||||||
|
document.getElementById('result-hit-title').textContent = hitTitle;
|
||||||
|
document.getElementById('result-hit-link').href =
|
||||||
|
'https://en.wikipedia.org/wiki/' + encodeURIComponent(hitTitle.replace(/ /g, '_'));
|
||||||
|
document.getElementById('diff-table').innerHTML = data.diff;
|
||||||
|
document.getElementById('hit-input').value = hitTitle;
|
||||||
|
|
||||||
|
const skipUrl = new URL(pageUrl);
|
||||||
|
skipUrl.searchParams.delete('title');
|
||||||
|
skipUrl.searchParams.set('after', hitTitle);
|
||||||
|
document.getElementById('skip-link').href = skipUrl.toString();
|
||||||
|
|
||||||
|
const currentUrl = new URL(pageUrl);
|
||||||
|
currentUrl.searchParams.delete('after');
|
||||||
|
currentUrl.searchParams.set('title', hitTitle);
|
||||||
|
history.replaceState(null, '', currentUrl.toString());
|
||||||
|
|
||||||
|
elResult.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elProgress.hidden = true;
|
||||||
|
elAllDone.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
search();
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
</form>
|
</form>
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<span class="text-light small opacity-75">{{ g.user }}</span>
|
<span class="text-light small opacity-75">{{ g.user }}</span>
|
||||||
|
{% if session.get("saves") %}
|
||||||
|
<span class="badge bg-success">{{ session["saves"] }} saved</span>
|
||||||
|
{% endif %}
|
||||||
<a class="btn btn-outline-light btn-sm" href="{{ url_for('oauth_disconnect') }}">Log out</a>
|
<a class="btn btn-outline-light btn-sm" href="{{ url_for('oauth_disconnect') }}">Log out</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-outline-light btn-sm" href="{{ url_for('start_oauth') }}">Log in with Wikipedia</a>
|
<a class="btn btn-outline-light btn-sm" href="{{ url_for('start_oauth') }}">Log in with Wikipedia</a>
|
||||||
|
|
|
||||||
68
web_view.py
68
web_view.py
|
|
@ -292,31 +292,6 @@ def match_type(q: str, snippet: str) -> str | None:
|
||||||
return match
|
return match
|
||||||
|
|
||||||
|
|
||||||
class NoGoodHit(Exception):
|
|
||||||
"""No good hit."""
|
|
||||||
|
|
||||||
|
|
||||||
def get_best_hit(title: str, hits: list[Hit]) -> tuple[Hit, dict[str, typing.Any]]:
|
|
||||||
"""Find the best hit within the search results."""
|
|
||||||
for hit in hits:
|
|
||||||
if hit["title"].lower() == title.lower():
|
|
||||||
continue
|
|
||||||
# if match_type(title, hit["snippet"]) != "exact":
|
|
||||||
# continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f'get diff: {hit["title"]}, {title}')
|
|
||||||
found = get_diff(title, hit["title"], None)
|
|
||||||
except NoMatch:
|
|
||||||
print("no match")
|
|
||||||
continue
|
|
||||||
except api.MediawikiError as e:
|
|
||||||
print(f"MediawikiError for {hit['title']!r}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return (hit, found)
|
|
||||||
|
|
||||||
raise NoGoodHit
|
|
||||||
|
|
||||||
|
|
||||||
def handle_post(url_title: str) -> Response:
|
def handle_post(url_title: str) -> Response:
|
||||||
|
|
@ -329,6 +304,7 @@ def handle_post(url_title: str) -> Response:
|
||||||
return flask.redirect(flask.url_for("start_oauth"))
|
return flask.redirect(flask.url_for("start_oauth"))
|
||||||
except mediawiki_api.APIError as e:
|
except mediawiki_api.APIError as e:
|
||||||
return flask.make_response(f"Save failed: {e}", 502)
|
return flask.make_response(f"Save failed: {e}", 502)
|
||||||
|
flask.session["saves"] = flask.session.get("saves", 0) + 1
|
||||||
return flask.redirect(
|
return flask.redirect(
|
||||||
flask.url_for("article_page", url_title=url_title, after=hit_title)
|
flask.url_for("article_page", url_title=url_title, after=hit_title)
|
||||||
)
|
)
|
||||||
|
|
@ -341,35 +317,25 @@ def article_page(url_title: str) -> str | Response:
|
||||||
return handle_post(url_title)
|
return handle_post(url_title)
|
||||||
|
|
||||||
from_title = url_title.replace("_", " ").strip()
|
from_title = url_title.replace("_", " ").strip()
|
||||||
article_title = flask.request.args.get("title")
|
|
||||||
|
|
||||||
total = search_count(from_title)
|
total = search_count(from_title)
|
||||||
with_link = search_count_with_link(from_title)
|
with_link = search_count_with_link(from_title)
|
||||||
|
_no_link_count, hits = search_no_link(from_title)
|
||||||
|
|
||||||
no_link_count, hits = search_no_link(from_title)
|
# If a specific candidate was requested, move it to the front
|
||||||
|
title_param = flask.request.args.get("title")
|
||||||
|
if title_param:
|
||||||
|
hits = [h for h in hits if h["title"] == title_param] + \
|
||||||
|
[h for h in hits if h["title"] != title_param]
|
||||||
|
|
||||||
by_title = {hit["title"]: hit for hit in hits}
|
# Skip past already-processed candidates
|
||||||
|
|
||||||
found = None
|
|
||||||
if article_title in by_title:
|
|
||||||
hit = by_title[article_title]
|
|
||||||
try:
|
|
||||||
found = get_diff(from_title, hit["title"], None)
|
|
||||||
except NoMatch:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
after = flask.request.args.get("after")
|
after = flask.request.args.get("after")
|
||||||
if after:
|
if after:
|
||||||
print(after)
|
hits_iter = itertools.dropwhile(lambda h: h["title"] != after, hits)
|
||||||
hits_iter = itertools.dropwhile(lambda hit: hit["title"] != after, hits)
|
next(hits_iter, None) # consume the "after" entry itself
|
||||||
skip = next(hits_iter, None)
|
|
||||||
if skip:
|
|
||||||
hits = list(hits_iter)
|
hits = list(hits_iter)
|
||||||
|
|
||||||
try:
|
if not hits:
|
||||||
hit, found = get_best_hit(from_title, hits)
|
|
||||||
except NoGoodHit:
|
|
||||||
return flask.render_template("all_done.html")
|
return flask.render_template("all_done.html")
|
||||||
|
|
||||||
return flask.render_template(
|
return flask.render_template(
|
||||||
|
|
@ -377,11 +343,7 @@ def article_page(url_title: str) -> str | Response:
|
||||||
title=from_title,
|
title=from_title,
|
||||||
total=total,
|
total=total,
|
||||||
with_link=with_link,
|
with_link=with_link,
|
||||||
hit_title=hit["title"],
|
|
||||||
hits=hits,
|
hits=hits,
|
||||||
replacement=found["replacement"],
|
|
||||||
diff=found["diff"],
|
|
||||||
found=found,
|
|
||||||
url_title=url_title,
|
url_title=url_title,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -428,16 +390,16 @@ def api_hits() -> werkzeug.wrappers.response.Response:
|
||||||
|
|
||||||
@app.route("/api/1/valid_hit")
|
@app.route("/api/1/valid_hit")
|
||||||
def api_valid_hit() -> werkzeug.wrappers.response.Response:
|
def api_valid_hit() -> werkzeug.wrappers.response.Response:
|
||||||
"""Return candidates for the given article title."""
|
"""Check if a candidate article has a valid unlinked mention."""
|
||||||
link_from = flask.request.args["link_from"]
|
|
||||||
link_to = flask.request.args["link_to"]
|
link_to = flask.request.args["link_to"]
|
||||||
|
link_from = flask.request.args["link_from"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
diff, replacement = get_diff(link_to, link_from, None)
|
found = get_diff(link_to, link_from, None)
|
||||||
except NoMatch:
|
except NoMatch:
|
||||||
return flask.jsonify(valid=False)
|
return flask.jsonify(valid=False)
|
||||||
|
|
||||||
return flask.jsonify(valid=True, diff=diff, replacement=replacement)
|
return flask.jsonify(valid=True, diff=found["diff"], replacement=found["replacement"])
|
||||||
|
|
||||||
|
|
||||||
@app.route("/favicon.ico")
|
@app.route("/favicon.ico")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue