- A30 — `require_scope()` câblé : 69 sites stricts de api_v2.py passent par la factory (Bearer + scope en 1 appel, contrôle manuel supprimé) ; sémantique alignée sur celle des handlers (pas de default "read" → 0 changement de comportement) ; 12 top-level morts supprimés (0 ref app ET tests) : unsync_block, find_referring, _b64url, strip_markdown, format_number, get_auto_property_value, get_next_unique_id, local_date_in_tz, verify_device_token, _get_dynamic_groups, _require_user_gitea, validate_upload_request - A37 — CORS sans `*` : origines = app_base_url + allow_origin_regex (localhost/dev, origines d'extension pour le Web Clipper), méthodes et entêtes minutées, allow_credentials explicite + test test_cors_no_star - A39 — htmx : décision « rien » documentée (32 attributs hx-* réels sur 6 templates, conversion = refonte du view-switching sans test E2E) - A40 — version d'assets à source unique : ENV.globals["asset_version"] lu au boot depuis le fichier VERSION ; littéraux `?v=` de base.html éliminés ; test test_asset_version_single_source - A41 — app.css : 91 règles mortes purgées (-10 274 octets, 121 618 → 111 344), scan templates/JS/CSS/Python à 0 référence suite **1031/1031** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.4.0 docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
101 lines
3.2 KiB
Python
101 lines
3.2 KiB
Python
"""FlowDeck — shared helpers for note importers (frontmatter, wikilinks)."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
try: # PyYAML ships transitively via uvicorn[standard]
|
|
import yaml
|
|
except Exception: # pragma: no cover - fallback parser below
|
|
yaml = None
|
|
|
|
|
|
_FRONTMATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
|
_WIKILINK_RE = re.compile(r"(!?)\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
|
|
|
|
|
|
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
"""Split YAML frontmatter from the body. Returns ``(metadata, body)``."""
|
|
m = _FRONTMATTER_RE.match(text)
|
|
if not m:
|
|
return {}, text
|
|
raw = m.group(1)
|
|
body = text[m.end():]
|
|
if yaml is not None:
|
|
try:
|
|
meta = yaml.safe_load(raw)
|
|
if isinstance(meta, dict):
|
|
return meta, body
|
|
except Exception: # noqa: BLE001 - fall back to the simple parser
|
|
pass
|
|
return _simple_yaml(raw), body
|
|
|
|
|
|
def _simple_yaml(raw: str) -> dict[str, Any]:
|
|
"""Minimal YAML subset parser (scalars, inline lists, block lists)."""
|
|
meta: dict[str, Any] = {}
|
|
current: str | None = None
|
|
for line in raw.splitlines():
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
if line.lstrip().startswith("- ") and current:
|
|
meta.setdefault(current, [])
|
|
if isinstance(meta[current], list):
|
|
meta[current].append(_scalar(line.lstrip()[2:].strip()))
|
|
continue
|
|
if ":" in line:
|
|
key, _, value = line.partition(":")
|
|
key = key.strip()
|
|
value = value.strip()
|
|
current = key
|
|
if not value:
|
|
meta[key] = []
|
|
elif value.startswith("[") and value.endswith("]"):
|
|
inner = value[1:-1].strip()
|
|
meta[key] = [_scalar(v.strip()) for v in inner.split(",") if v.strip()] if inner else []
|
|
else:
|
|
meta[key] = _scalar(value)
|
|
return meta
|
|
|
|
|
|
def _scalar(value: str) -> Any:
|
|
v = value.strip().strip('"').strip("'")
|
|
if v.lower() in ("true", "false"):
|
|
return v.lower() == "true"
|
|
if re.fullmatch(r"-?\d+", v):
|
|
return int(v)
|
|
if re.fullmatch(r"-?\d+\.\d+", v):
|
|
return float(v)
|
|
return v
|
|
|
|
|
|
def convert_wikilinks(text: str, *, embeds: bool = True) -> str:
|
|
"""Turn Obsidian/Logseq ``[[link]]`` into Markdown links and ``![[img]]``
|
|
into Markdown images so the block converter can render them."""
|
|
|
|
def repl(m: re.Match) -> str:
|
|
bang, target, alias = m.group(1), m.group(2).strip(), m.group(3)
|
|
label = (alias or target).strip()
|
|
if bang == "!":
|
|
return f"" if embeds else label
|
|
return f"[{label}]({target})"
|
|
|
|
return _WIKILINK_RE.sub(repl, text)
|
|
|
|
|
|
def normalize_title(value: Any) -> str:
|
|
return str(value).strip() if value is not None else ""
|
|
|
|
|
|
def coerce_tags(value: Any) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return [str(v).strip().lstrip("#") for v in value if str(v).strip()]
|
|
if isinstance(value, str):
|
|
parts = re.split(r"[,\s]+", value)
|
|
return [p.strip().lstrip("#") for p in parts if p.strip()]
|
|
return [str(value)]
|
|
|
|
|