Render frontmatter as styled cards in public share view

Split search query tokens on word boundaries for accurate inverted-index
matching
This commit is contained in:
Bruno Charest 2026-05-26 22:16:21 -04:00
parent dc9684e56c
commit 7c4f2964eb
2 changed files with 41 additions and 10 deletions

View File

@ -2866,6 +2866,25 @@ async def public_share_view(token: str):
post = parse_markdown_file(raw) post = parse_markdown_file(raw)
html = _render_markdown(post.content, share["vault"], file_path) html = _render_markdown(post.content, share["vault"], file_path)
title = post.metadata.get("title", file_path.stem) title = post.metadata.get("title", file_path.stem)
# Build frontmatter section HTML
fm_html = ""
if post.metadata:
fm_items = []
skip_keys = {"title", "titre"}
for k, v in post.metadata.items():
if k in skip_keys:
continue
if isinstance(v, list):
v = ", ".join(str(x) for x in v)
elif isinstance(v, bool):
v = "" if v else ""
elif v is None:
v = ""
fm_items.append(f'<div class="fm-row"><span class="fm-key">{k}</span><span class="fm-val">{v}</span></div>')
if fm_items:
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} ObsiGate Share</title> <title>{title} ObsiGate Share</title>
<style> <style>
@ -2890,6 +2909,12 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}} .content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
.content pre code{{background:none;padding:0}} .content pre code{{background:none;padding:0}}
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}} .content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
.fm-row{{display:contents}}
.fm-key{{color:var(--accent);font-weight:500}}
.fm-val{{color:var(--text);word-break:break-word}}
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}} .content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
.content table{{border-collapse:collapse;width:100%;margin:12px 0}} .content table{{border-collapse:collapse;width:100%;margin:12px 0}}
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}} .content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
@ -2917,7 +2942,7 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
PDF PDF
</button> </button>
</div> </div>
<div class="content" id="content">{html}</div> <div class="content" id="content">{fm_html}{html}</div>
<script> <script>
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}} function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})(); (function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();

View File

@ -762,6 +762,7 @@ def _split_query_tokens(raw: str) -> List[str]:
def _passes_search_filters( def _passes_search_filters(
file_info: dict, file_info: dict,
query_terms: List[str], query_terms: List[str],
query_terms_raw: List[str],
raw_query: str, raw_query: str,
case_sensitive: bool, case_sensitive: bool,
whole_word: bool, whole_word: bool,
@ -774,6 +775,7 @@ def _passes_search_filters(
content = file_info.get("content", "") content = file_info.get("content", "")
path = file_info.get("path", "") path = file_info.get("path", "")
search_text = f"{title} {content}" search_text = f"{title} {content}"
search_text_norm = normalize_text(search_text)
# --- Regex mode --- # --- Regex mode ---
if regex and raw_query: if regex and raw_query:
@ -786,20 +788,20 @@ def _passes_search_filters(
if not pattern.search(search_text): if not pattern.search(search_text):
return False return False
except re.error: except re.error:
return False # invalid regex return False
return _passes_path_filters(path, include_paths, exclude_paths) return _passes_path_filters(path, include_paths, exclude_paths)
# --- Case-sensitive --- # --- Case-sensitive (use raw, non-normalized terms) ---
if case_sensitive and query_terms: if case_sensitive and query_terms_raw:
for term in query_terms: for term in query_terms_raw:
if term not in search_text: if term not in search_text:
return False return False
# --- Whole-word --- # --- Whole-word (use normalized text + normalized terms) ---
if whole_word and query_terms: if whole_word and query_terms:
for term in query_terms: for term in query_terms:
pattern = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE) pattern = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
if not pattern.search(search_text): if not pattern.search(search_text_norm):
return False return False
# --- Path filters (glob-like) --- # --- Path filters (glob-like) ---
@ -870,8 +872,12 @@ def advanced_search(
# Vault filter — parsed vault: overrides parameter # Vault filter — parsed vault: overrides parameter
effective_vault = parsed["vault"] or vault_filter effective_vault = parsed["vault"] or vault_filter
# Normalize free-text terms # Tokenize free-text terms (splits on non-word chars like dots)
query_terms = [normalize_text(t) for t in parsed["terms"] if t.strip()] # "192.168" → ["192", "168"] for proper inverted index matching
query_terms_raw = [t for t in parsed["terms"] if t.strip()]
query_terms = []
for t in query_terms_raw:
query_terms.extend(tokenize(t))
has_terms = len(query_terms) > 0 has_terms = len(query_terms) > 0
if not has_terms and not all_tags and not parsed["title"] and not parsed["path"] and not parsed["ext"]: if not has_terms and not all_tags and not parsed["title"] and not parsed["path"] and not parsed["ext"]:
@ -991,7 +997,7 @@ def advanced_search(
if score > 0: if score > 0:
# --- Post-filters: case-sensitive, whole-word, regex, path filters --- # --- Post-filters: case-sensitive, whole-word, regex, path filters ---
if not _passes_search_filters( if not _passes_search_filters(
file_info, query_terms, query, file_info, query_terms, query_terms_raw, " ".join(query_terms_raw) if query_terms_raw else query,
case_sensitive, whole_word, regex, include_paths, exclude_paths case_sensitive, whole_word, regex, include_paths, exclude_paths
): ):
continue continue