Redesign UI and fix several bugs
- Add navbar with login/logout, search form, and Find Link branding
- Clean up index page: search-only, examples behind ?debug=1
- Improve article page: remove debug clutter, named Wikipedia links, collapsible candidates
- Add SVG favicon (🔗 emoji)
- Fix diff CSS: compact layout, auto table layout to eliminate wide marker column gap
- Catch TokenRequestDenied in OAuth start and show error page
- Store username in session at login; clear bad session on API failure
- Raise NoMatch when diff is empty (edit already applied)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7867122326
commit
bc6265d4cd
11 changed files with 167 additions and 90 deletions
|
|
@ -559,4 +559,6 @@ def get_diff(q: str, title: str, linkto: str | None) -> dict[str, typing.Any]:
|
||||||
)
|
)
|
||||||
|
|
||||||
found["diff"] = call_get_diff(title, found["section_num"], section_text)
|
found["diff"] = call_get_diff(title, found["section_num"], section_text)
|
||||||
|
if not found["diff"]:
|
||||||
|
raise NoMatch
|
||||||
return found
|
return found
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Wikipedia OAuth."""
|
"""Wikipedia OAuth."""
|
||||||
|
|
||||||
|
import sys
|
||||||
import typing
|
import typing
|
||||||
import urllib
|
import urllib
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
@ -73,9 +74,8 @@ def api_request(params: typing.Mapping[str, str | int]) -> dict[str, typing.Any]
|
||||||
try:
|
try:
|
||||||
return cast(dict[str, typing.Any], r.json())
|
return cast(dict[str, typing.Any], r.json())
|
||||||
except Exception:
|
except Exception:
|
||||||
print("text")
|
print(f"API request failed: HTTP {r.status_code}", file=sys.stderr)
|
||||||
print(r.text)
|
print(f"Response body: {r.text!r}", file=sys.stderr)
|
||||||
print("---")
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -105,7 +105,13 @@ def get_username() -> None | str:
|
||||||
return None # not authorized
|
return None # not authorized
|
||||||
|
|
||||||
if "username" not in session:
|
if "username" not in session:
|
||||||
|
try:
|
||||||
reply = userinfo_call()
|
reply = userinfo_call()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"get_username failed, clearing session: {e}", file=sys.stderr)
|
||||||
|
session.pop("owner_key", None)
|
||||||
|
session.pop("owner_secret", None)
|
||||||
|
return None
|
||||||
if "query" not in reply:
|
if "query" not in reply:
|
||||||
return None
|
return None
|
||||||
session["username"] = reply["query"]["userinfo"]["name"]
|
session["username"] = reply["query"]["userinfo"]["name"]
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,16 @@ span.searchmatch { font-weight: bold; }
|
||||||
|
|
||||||
table.diff,td.diff-otitle,td.diff-ntitle{background-color:white}
|
table.diff,td.diff-otitle,td.diff-ntitle{background-color:white}
|
||||||
td.diff-otitle,td.diff-ntitle{text-align:center}
|
td.diff-otitle,td.diff-ntitle{text-align:center}
|
||||||
td.diff-marker{text-align:right;font-weight:bold;font-size:1.25em}
|
td.diff-marker{width:1.5em;text-align:center;font-weight:bold;font-size:1.25em;padding:0 0.3em}
|
||||||
td.diff-lineno{font-weight:bold}
|
td.diff-lineno{font-weight:bold}
|
||||||
td.diff-addedline,td.diff-deletedline,td.diff-context{font-size:88%;vertical-align:top;white-space:-moz-pre-wrap;white-space:pre-wrap}
|
td.diff-addedline,td.diff-deletedline,td.diff-context{font-size:88%;vertical-align:top;white-space:-moz-pre-wrap;white-space:pre-wrap}
|
||||||
td.diff-addedline,td.diff-deletedline{border-style:solid;border-width:1px 1px 1px 4px;border-radius:0.33em}
|
td.diff-addedline,td.diff-deletedline{border-left:3px solid}
|
||||||
td.diff-addedline{border-color:#a3d3ff}
|
td.diff-addedline{border-color:#a3d3ff;background:#f0f8ff}
|
||||||
td.diff-deletedline{border-color:#ffe49c}
|
td.diff-deletedline{border-color:#ffe49c;background:#fffaf0}
|
||||||
td.diff-context{background:#f3f3f3;color:#333333;border-style:solid;border-width:1px 1px 1px 4px;border-color:#e6e6e6;border-radius:0.33em}
|
td.diff-context{color:#555}
|
||||||
.diffchange{font-weight:bold;text-decoration:none}
|
.diffchange{font-weight:bold;text-decoration:none}
|
||||||
table.diff{border:none;width:98%;border-spacing:4px; table-layout:fixed}
|
table.diff{border:none;width:100%;border-spacing:0;border-collapse:collapse;table-layout:auto}
|
||||||
td.diff-addedline .diffchange,td.diff-deletedline .diffchange{border-radius:0.33em;padding:0.25em 0}
|
|
||||||
td.diff-addedline .diffchange{background:#d8ecff}
|
td.diff-addedline .diffchange{background:#d8ecff}
|
||||||
td.diff-deletedline .diffchange{background:#feeec8}
|
td.diff-deletedline .diffchange{background:#feeec8}
|
||||||
table.diff td{padding:0.33em 0.66em}
|
table.diff td{padding:0.2em 0.5em}
|
||||||
table.diff col.diff-marker{width:2%}
|
table.diff td div{word-wrap:break-word;overflow:auto}
|
||||||
table.diff col.diff-content{width:48%}
|
|
||||||
table.diff td div{ word-wrap:break-word; overflow:auto}
|
|
||||||
|
|
|
||||||
3
static/favicon.svg
Normal file
3
static/favicon.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<text y=".9em" font-size="90">🔗</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 114 B |
|
|
@ -1,10 +1,11 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Index{% endblock %}
|
{% block title %}All done{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container text-center mt-5">
|
||||||
<h1>All done</h1>
|
<h1 class="mb-3">All done</h1>
|
||||||
<div><a href="{{ url_for('index') }}">back to index </a></div>
|
<p class="text-muted mb-4">No more candidates found for this article.</p>
|
||||||
</div>
|
<a href="{{ url_for('index') }}" class="btn btn-primary">Search another article</a>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,54 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Link '{{ title }}' in '{{ hit_title }}'{% endblock %}
|
{% block title %}{{ title }} in {{ hit_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"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Link '{{ title }}' in '{{ hit_title }}'</h1>
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<form action="{{ url_for("index") }}">
|
<ol class="breadcrumb">
|
||||||
<input name="q">
|
<li class="breadcrumb-item"><a href="{{ url_for('index') }}">Home</a></li>
|
||||||
<input type="submit" value="search">
|
<li class="breadcrumb-item active">{{ title }}</li>
|
||||||
</form>
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div>Username: {{ g.user }}</div>
|
<div class="d-flex flex-wrap align-items-baseline gap-3 mb-1">
|
||||||
|
<h1 class="h4 mb-0">Link "{{ title }}" in "{{ hit_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/{{ hit_title }}" target="_blank" class="text-muted small">{{ hit_title }} ↗</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div><a href="https://en.wikipedia.org/wiki/{{ title }}" target="_blank">view article</a></div>
|
<div class="d-flex gap-3 mb-4 text-muted small">
|
||||||
|
<span>{{ total }} mentions total</span>
|
||||||
|
<span>{{ with_link }} already linked ({{ "{:.0%}".format(with_link / total) }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div><a href="{{ url_for('index') }}">back to index </a></div>
|
<div class="mb-4">
|
||||||
|
<table class="diff">{{ diff | safe }}</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>total: {{ total }}</div>
|
<form method="POST" class="mb-4">
|
||||||
<div>with link: {{ with_link }}</div>
|
|
||||||
<div>ratio: {{ "{:.1%}".format(with_link / total) }}</div>
|
|
||||||
{# <div>hit: {{ hit }}</div> #}
|
|
||||||
<div>replacement: {{ found.replacement }}</div>
|
|
||||||
<div>section: {{ found.section }}</div>
|
|
||||||
<table>
|
|
||||||
{{ diff | safe }}
|
|
||||||
</table>
|
|
||||||
<form method="POST">
|
|
||||||
<input type="hidden" name="hit" value="{{ hit_title }}">
|
<input type="hidden" name="hit" value="{{ hit_title }}">
|
||||||
<div class="my-3">
|
<div class="d-flex gap-2">
|
||||||
<input type="submit" class="btn btn-primary" value="save"/>
|
<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-primary">skip</a>
|
<a href="{{ url_for("article_page", url_title=url_title, after=hit_title) }}" class="btn btn-outline-secondary">Skip</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ol>
|
{% if hits %}
|
||||||
|
<details class="border rounded p-3">
|
||||||
|
<summary class="text-muted small" style="cursor:pointer">{{ hits | length }} other candidates</summary>
|
||||||
|
<ol class="mt-3 mb-0 small">
|
||||||
{% for hit in hits %}
|
{% for hit in hits %}
|
||||||
{% set url = url_for("article_page", url_title=url_title, title=hit.title) %}
|
<li class="mb-1">
|
||||||
<li><a href="{{ url }}">{{ hit.title }}</a> – {{ hit.snippet | safe }}</li>
|
<a href="{{ url_for("article_page", url_title=url_title, title=hit.title) }}">{{ hit.title }}</a>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,33 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
|
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
<title>
|
<title>{% block title %}{% endblock %} – Find Link</title>
|
||||||
{% block title %}{% endblock %}
|
|
||||||
</title>
|
|
||||||
|
|
||||||
{% block style %}{% endblock %}
|
{% block style %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand fw-semibold" href="{{ url_for('index') }}">Find Link</a>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<form class="d-flex" action="{{ url_for('index') }}">
|
||||||
|
<input class="form-control form-control-sm me-2" name="q" placeholder="Article title…" style="width:240px">
|
||||||
|
<button class="btn btn-outline-light btn-sm" type="submit">Go</button>
|
||||||
|
</form>
|
||||||
|
{% if g.user %}
|
||||||
|
<span class="text-light small opacity-75">{{ g.user }}</span>
|
||||||
|
<a class="btn btn-outline-light btn-sm" href="{{ url_for('oauth_disconnect') }}">Log out</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-outline-light btn-sm" href="{{ url_for('start_oauth') }}">Log in with Wikipedia</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js")}}></script>
|
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script>
|
||||||
|
|
||||||
{% block script %}{% endblock %}
|
{% block script %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
17
templates/error.html
Normal file
17
templates/error.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Error{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<h4 class="alert-heading">Something went wrong</h4>
|
||||||
|
<p class="mb-0"><code>{{ message }}</code></p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary btn-sm">Back to home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,25 +1,44 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Index{% endblock %}
|
{% block title %}Find Link{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Index</h1>
|
<div class="row justify-content-center mt-5">
|
||||||
<form>
|
<div class="col-md-6 text-center">
|
||||||
<input name="q">
|
<h1 class="mb-2">Find Link</h1>
|
||||||
<input type="submit" value="search">
|
<p class="text-muted mb-4">Find unlinked mentions of a Wikipedia article and add the links.</p>
|
||||||
|
<form class="d-flex gap-2 justify-content-center" action="{{ url_for('index') }}">
|
||||||
|
<input class="form-control" name="q" placeholder="Article title…" style="max-width:360px" autofocus>
|
||||||
|
<button class="btn btn-primary" type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>Username: {{ g.user }}</div>
|
{% if debug %}
|
||||||
|
<div class="row mt-5">
|
||||||
<table class="table w-auto">
|
<div class="col">
|
||||||
|
<h2 class="h6 text-muted text-uppercase mb-3">Examples</h2>
|
||||||
|
<table class="table table-sm table-hover w-auto">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Article</th>
|
||||||
|
<th class="text-end">Total</th>
|
||||||
|
<th class="text-end">% linked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{% for item in examples %}
|
{% for item in examples %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ article_url(item.title) }}">{{ item.title }}</a></td>
|
<td><a href="{{ article_url(item.title) }}">{{ item.title }}</a></td>
|
||||||
<td>{{ item.total }}</td>
|
<td class="text-end text-muted">{{ item.total }}</td>
|
||||||
<td>{{ "{:.1%}".format(item.with_links / item.total) }}</td>
|
<td class="text-end text-muted">{{ "{:.0%}".format(item.with_links / item.total) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Index{% endblock %}
|
{% block title %}Edit saved{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container text-center mt-5">
|
||||||
<h1>Save done</h1>
|
<h1 class="mb-3">Edit saved</h1>
|
||||||
<div>Save is complete.</div>
|
<p class="text-muted mb-4">Your edit has been saved to Wikipedia.</p>
|
||||||
</div>
|
<a href="{{ url_for('index') }}" class="btn btn-primary">Search another article</a>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
23
web_view.py
23
web_view.py
|
|
@ -4,11 +4,13 @@ import html
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
import werkzeug
|
import werkzeug
|
||||||
from requests_oauthlib import OAuth1Session
|
from requests_oauthlib import OAuth1Session
|
||||||
|
from requests_oauthlib.oauth1_session import TokenRequestDenied
|
||||||
from werkzeug.wrappers.response import Response
|
from werkzeug.wrappers.response import Response
|
||||||
|
|
||||||
from add_links import api, core, mediawiki_api, mediawiki_oauth
|
from add_links import api, core, mediawiki_api, mediawiki_oauth
|
||||||
|
|
@ -118,17 +120,20 @@ def index() -> str | Response:
|
||||||
url = flask.url_for("oauth_callback", **flask.request.args) # type: ignore
|
url = flask.url_for("oauth_callback", **flask.request.args) # type: ignore
|
||||||
return flask.redirect(url)
|
return flask.redirect(url)
|
||||||
|
|
||||||
|
if q := flask.request.args.get("q"):
|
||||||
|
if q_trimmed := q.strip():
|
||||||
|
return flask.redirect(article_url(q_trimmed))
|
||||||
|
|
||||||
|
debug = flask.request.args.get("debug")
|
||||||
|
examples: list[dict[str, str | int]] = []
|
||||||
|
if debug:
|
||||||
examples = load_examples()
|
examples = load_examples()
|
||||||
examples.sort(
|
examples.sort(
|
||||||
key=lambda i: float(i["with_links"]) / float(i["total"]), reverse=True
|
key=lambda i: float(i["with_links"]) / float(i["total"]), reverse=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if q := flask.request.args.get("q"):
|
|
||||||
if q_trimmed := q.strip():
|
|
||||||
return flask.redirect(article_url(q_trimmed))
|
|
||||||
|
|
||||||
return flask.render_template(
|
return flask.render_template(
|
||||||
"index.html", examples=examples, article_url=article_url
|
"index.html", examples=examples, article_url=article_url, debug=debug
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -187,7 +192,12 @@ def start_oauth() -> Response:
|
||||||
|
|
||||||
oauth = OAuth1Session(client_key, client_secret=client_secret, callback_uri="oob")
|
oauth = OAuth1Session(client_key, client_secret=client_secret, callback_uri="oob")
|
||||||
oauth.headers.update({"User-Agent": api.ua})
|
oauth.headers.update({"User-Agent": api.ua})
|
||||||
|
try:
|
||||||
fetch_response = oauth.fetch_request_token(request_token_url)
|
fetch_response = oauth.fetch_request_token(request_token_url)
|
||||||
|
except TokenRequestDenied as e:
|
||||||
|
return flask.make_response(
|
||||||
|
flask.render_template("error.html", message=str(e)), 502
|
||||||
|
)
|
||||||
|
|
||||||
flask.session["owner_key"] = fetch_response.get("oauth_token")
|
flask.session["owner_key"] = fetch_response.get("oauth_token")
|
||||||
flask.session["owner_secret"] = fetch_response.get("oauth_token_secret")
|
flask.session["owner_secret"] = fetch_response.get("oauth_token_secret")
|
||||||
|
|
@ -229,7 +239,8 @@ def oauth_callback() -> werkzeug.wrappers.response.Response:
|
||||||
flask.session["owner_key"] = oauth_tokens.get("oauth_token")
|
flask.session["owner_key"] = oauth_tokens.get("oauth_token")
|
||||||
flask.session["owner_secret"] = oauth_tokens.get("oauth_token_secret")
|
flask.session["owner_secret"] = oauth_tokens.get("oauth_token_secret")
|
||||||
|
|
||||||
print("login successful")
|
username = mediawiki_oauth.get_username()
|
||||||
|
print(f"login successful: {username}", file=sys.stderr)
|
||||||
|
|
||||||
next_page = flask.session.get("after_login")
|
next_page = flask.session.get("after_login")
|
||||||
return flask.redirect(next_page if next_page else flask.url_for("index"))
|
return flask.redirect(next_page if next_page else flask.url_for("index"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue