feat(wiki): [[wikilinks]] with redlinks + cross-project resolver (Issue B)
Last P2 from Antoine's "daily-usable" sprint. Entities referenced via [[Name]] in descriptions or mirror markdown now render as: - live wikilink if the name matches an entity in the same project - live cross-project link with "(in project X)" scope indicator if the only match is in another project - red italic redlink pointing at /wiki/new?name=... otherwise Clicking a redlink opens a pre-filled "create this entity" form that POSTs to /v1/entities and redirects to the new entity's page. - engineering/wiki.py: _wikilink_transform + _resolve_wikilink, applied in render_project (pre-markdown) and render_entity (description body). render_new_entity_form for the create page. CSS for .wikilink / .wikilink-cross / .redlink / .new-entity-form - api/routes.py: GET /wiki/new?name&project - tests/test_wikilinks.py: 12 tests including the spec regression (A references [[B]] -> redlink; create B -> link becomes live) - DEV-LEDGER.md: session log + test_count 521 -> 533 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -262,6 +262,10 @@ def render_project(project: str) -> str:
|
||||
from atocore.engineering.mirror import generate_project_overview
|
||||
|
||||
markdown_content = generate_project_overview(project)
|
||||
# Resolve [[Wikilinks]] before markdown so redlinks / cross-project
|
||||
# indicators appear in the rendered HTML. (Issue B)
|
||||
markdown_content = _wikilink_transform(markdown_content, current_project=project)
|
||||
|
||||
# Convert entity names to links
|
||||
entities = get_entities(project=project, limit=200)
|
||||
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
|
||||
@@ -277,6 +281,145 @@ def render_project(project: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
_WIKILINK_PATTERN = _re.compile(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]+?))?\]\]")
|
||||
|
||||
|
||||
def _resolve_wikilink(target: str, current_project: str | None) -> tuple[str, str, str]:
|
||||
"""Resolve a ``[[Name]]`` target to ``(href, css_class, extra_suffix)``.
|
||||
|
||||
Resolution order (Issue B):
|
||||
|
||||
1. Same-project exact name match → live link (class ``wikilink``).
|
||||
2. Other-project exact name match → live link with ``(in project X)``
|
||||
suffix (class ``wikilink wikilink-cross``).
|
||||
3. No match → redlink pointing at ``/wiki/new?name=...`` so clicking
|
||||
opens a pre-filled "create this entity" form (class ``redlink``).
|
||||
"""
|
||||
needle = target.strip()
|
||||
if not needle:
|
||||
return ("/wiki/new", "redlink", "")
|
||||
|
||||
same_project = None
|
||||
cross_project = None
|
||||
try:
|
||||
candidates = get_entities(name_contains=needle, limit=200)
|
||||
except Exception:
|
||||
candidates = []
|
||||
|
||||
lowered = needle.lower()
|
||||
for ent in candidates:
|
||||
if ent.name.lower() != lowered:
|
||||
continue
|
||||
if current_project and ent.project == current_project:
|
||||
same_project = ent
|
||||
break
|
||||
if cross_project is None:
|
||||
cross_project = ent
|
||||
|
||||
if same_project is not None:
|
||||
return (f"/wiki/entities/{same_project.id}", "wikilink", "")
|
||||
if cross_project is not None:
|
||||
suffix = (
|
||||
f' <span class="wikilink-scope">(in {cross_project.project})</span>'
|
||||
if cross_project.project
|
||||
else ' <span class="wikilink-scope">(global)</span>'
|
||||
)
|
||||
return (f"/wiki/entities/{cross_project.id}", "wikilink wikilink-cross", suffix)
|
||||
|
||||
from urllib.parse import quote
|
||||
href = f"/wiki/new?name={quote(needle)}"
|
||||
if current_project:
|
||||
href += f"&project={quote(current_project)}"
|
||||
return (href, "redlink", "")
|
||||
|
||||
|
||||
def _wikilink_transform(text: str, current_project: str | None) -> str:
|
||||
"""Replace ``[[Name]]`` / ``[[Name|Display]]`` tokens with HTML anchors.
|
||||
|
||||
Runs before markdown rendering. Emits raw HTML which python-markdown
|
||||
preserves unchanged.
|
||||
"""
|
||||
if not text or "[[" not in text:
|
||||
return text
|
||||
|
||||
def _sub(match: _re.Match) -> str:
|
||||
target = match.group(1)
|
||||
display = (match.group(2) or target).strip()
|
||||
href, cls, suffix = _resolve_wikilink(target, current_project)
|
||||
title = "create this entity" if cls == "redlink" else target.strip()
|
||||
return (
|
||||
f'<a href="{href}" class="{cls}" title="{_escape_attr(title)}">'
|
||||
f'{_escape_html(display)}</a>{suffix}'
|
||||
)
|
||||
|
||||
return _WIKILINK_PATTERN.sub(_sub, text)
|
||||
|
||||
|
||||
def render_new_entity_form(name: str = "", project: str = "") -> str:
|
||||
"""Issue B — "create this entity" form targeted by redlinks."""
|
||||
from atocore.engineering.service import ENTITY_TYPES
|
||||
|
||||
safe_name = _escape_attr(name or "")
|
||||
safe_project = _escape_attr(project or "")
|
||||
opts = "".join(
|
||||
f'<option value="{t}">{t}</option>' for t in ENTITY_TYPES
|
||||
)
|
||||
lines = [
|
||||
'<h1>Create entity</h1>',
|
||||
('<p>This entity was referenced via a wikilink but does not exist yet. '
|
||||
'Fill in the details to create it — the wiki link will resolve on reload.</p>'),
|
||||
'<form id="new-entity-form" class="new-entity-form">',
|
||||
f'<label>Name<br><input type="text" name="name" value="{safe_name}" required></label>',
|
||||
f'<label>Entity type<br><select name="entity_type" required>{opts}</select></label>',
|
||||
f'<label>Project<br><input type="text" name="project" value="{safe_project}" '
|
||||
f'placeholder="leave blank for cross-project / global"></label>',
|
||||
'<label>Description<br><textarea name="description" rows="4"></textarea></label>',
|
||||
'<button type="submit">Create</button>',
|
||||
'<div id="new-entity-result" class="new-entity-result"></div>',
|
||||
'</form>',
|
||||
"""<script>
|
||||
(function() {
|
||||
const form = document.getElementById('new-entity-form');
|
||||
const out = document.getElementById('new-entity-result');
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const body = {
|
||||
name: fd.get('name'),
|
||||
entity_type: fd.get('entity_type'),
|
||||
project: fd.get('project') || '',
|
||||
description: fd.get('description') || '',
|
||||
};
|
||||
try {
|
||||
const r = await fetch('/v1/entities', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (r.ok) {
|
||||
out.innerHTML = 'Created: <a href="/wiki/entities/' + j.id + '">' +
|
||||
(j.name || 'new entity') + '</a>';
|
||||
setTimeout(() => { window.location.href = '/wiki/entities/' + j.id; }, 800);
|
||||
} else {
|
||||
out.textContent = 'Error: ' + (j.detail || JSON.stringify(j));
|
||||
}
|
||||
} catch (e) {
|
||||
out.textContent = 'Network error: ' + e;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>""",
|
||||
]
|
||||
return render_html(
|
||||
f"Create {name}" if name else "Create entity",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Create entity", "")],
|
||||
)
|
||||
|
||||
|
||||
def _render_visual_evidence(entity_id: str, ctx: dict) -> str:
|
||||
"""Render EVIDENCED_BY → artifact links as an inline thumbnail strip."""
|
||||
from atocore.assets import get_asset
|
||||
@@ -397,7 +540,8 @@ def render_entity(entity_id: str) -> str | None:
|
||||
if ent.project:
|
||||
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
|
||||
if ent.description:
|
||||
lines.append(f'<p>{ent.description}</p>')
|
||||
desc_html = _wikilink_transform(ent.description, current_project=ent.project)
|
||||
lines.append(f'<p>{desc_html}</p>')
|
||||
if ent.properties:
|
||||
lines.append('<h2>Properties</h2><ul>')
|
||||
for k, v in ent.properties.items():
|
||||
@@ -917,6 +1061,17 @@ _TEMPLATE = """<!DOCTYPE html>
|
||||
.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; }
|
||||
.stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
|
||||
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
|
||||
.wikilink { color: var(--accent); text-decoration: none; border-bottom: 1px dashed transparent; }
|
||||
.wikilink:hover { border-bottom-color: var(--accent); }
|
||||
.wikilink-cross { border-bottom-style: dotted; }
|
||||
.wikilink-scope { font-size: 0.75em; opacity: 0.6; font-style: italic; }
|
||||
.redlink { color: #d0473d; text-decoration: none; font-style: italic; border-bottom: 1px dashed #d0473d; }
|
||||
.redlink:hover { background: rgba(208, 71, 61, 0.08); }
|
||||
.new-entity-form { display: flex; flex-direction: column; gap: 0.9rem; max-width: 520px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.2rem; }
|
||||
.new-entity-form label { display: flex; flex-direction: column; font-size: 0.88rem; opacity: 0.8; }
|
||||
.new-entity-form input, .new-entity-form select, .new-entity-form textarea { margin-top: 0.3rem; padding: 0.45rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 0.95rem; }
|
||||
.new-entity-form button { align-self: flex-start; padding: 0.5rem 1.1rem; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.new-entity-result { font-size: 0.9rem; opacity: 0.85; min-height: 1em; }
|
||||
.evidence-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0 1.25rem; }
|
||||
.evidence-tile { margin: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.4rem; max-width: 270px; }
|
||||
.evidence-tile img { display: block; max-width: 100%; height: auto; border-radius: 3px; }
|
||||
|
||||
Reference in New Issue
Block a user