Compare commits

..

2 Commits

Author SHA1 Message Date
Adrian Rumpold
abb415c380 Improved article selector with titles and structure 2025-04-25 09:18:53 +02:00
Adrian Rumpold
f21158c6c0 Correctly extract TOC text entries
The previous code could not correctly handle nested XML elements in the TOC text entries.
2025-04-25 09:18:23 +02:00
10 changed files with 98 additions and 52 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-query-devtools": "^5.74.6",
"dompurify": "^3.2.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.5.1", "react-router-dom": "^7.5.1",
@@ -1881,6 +1882,13 @@
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.31.0", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz",
@@ -2414,6 +2422,15 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/dompurify": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.5.0", "version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-query-devtools": "^5.74.6",
"dompurify": "^3.2.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.5.1", "react-router-dom": "^7.5.1",

View File

@@ -1,6 +1,3 @@
import { useQueries } from "@tanstack/react-query";
import { getArticleIds, getToc } from "./lib/api";
import { Language } from "./lib/types"; import { Language } from "./lib/types";
import ArticleSelector from "./components/ArticleSelector/ArticleSelector"; import ArticleSelector from "./components/ArticleSelector/ArticleSelector";
@@ -12,45 +9,29 @@ import useUIStore from "./store/uiStore";
import styles from "./App.module.css"; import styles from "./App.module.css";
import CelexSelector from "./components/CelexSelector/CelexSelector"; import CelexSelector from "./components/CelexSelector/CelexSelector";
import { useTOC } from "./hooks/toc";
function App() { function App() {
const { numPanels, addPanel } = useUIStore(); const { numPanels, addPanel } = useUIStore();
const { celexId, articleId } = useNavState(); const { celexId, articleId } = useNavState();
const { data: toc, isPending, error } = useTOC();
const results = useQueries({
queries: [
{
queryKey: ["articleIds", celexId],
queryFn: () => getArticleIds(celexId!),
enabled: !!celexId,
},
{
queryKey: ["toc", celexId],
queryFn: () => getToc(celexId!, Language.ENG),
enabled: !!celexId,
},
],
});
const isPending = results.some((result) => result.isPending);
const error = results.find((result) => result.isError);
if (isPending) { if (isPending) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
if (error) { if (error) {
return <div>Error: {error.error?.message}</div>; return <div>Error: {error.message}</div>;
} }
return ( return (
<div className={styles.App}> <div className={styles.App}>
<div className={styles.controls}> <div className={styles.controls}>
<CelexSelector /> <CelexSelector />
<ArticleSelector articleIds={results[0].data!} /> <ArticleSelector toc={toc} />
<button onClick={addPanel}>Add Panel</button> <button onClick={addPanel}>Add Panel</button>
</div> </div>
<div className={styles.panelContainer}> <div className={styles.panelContainer}>
<TOC toc={results[1].data!} /> <TOC toc={toc} />
{Array.from({ length: numPanels }, (_, index) => ( {Array.from({ length: numPanels }, (_, index) => (
<Panel <Panel
key={index} key={index}

View File

@@ -0,0 +1,4 @@
.articleSelector {
width: 40ch;
text-overflow: ellipsis;
}

View File

@@ -1,32 +1,54 @@
import { JSX } from "react";
import type { Division } from "../../lib/types";
import useNavState from "../../store/navStore"; import useNavState from "../../store/navStore";
import styles from "./ArticleSelector.module.css";
type ArticleSelectorProps = { type ArticleSelectorProps = {
articleIds: number[]; toc: Division[];
}; };
function ArticleSelector({ articleIds }: ArticleSelectorProps) { function ArticleSelector({ toc }: ArticleSelectorProps) {
const { articleId, setArticleId } = useNavState(); const { articleId, setArticleId } = useNavState();
function renderDivision(div: Division): JSX.Element {
const contents = div.content.map((c) => {
if (c.type === "division") {
return renderDivision(c);
} else {
return (
<option key={c.id} value={c.id}>
{c.title}: {c.subtitle}
</option>
);
}
});
if (div.level === 0) {
const title = `${div.title}: ${div.subtitle}`;
return (
// For top-level divisions, we can use optgroup
<optgroup key={title} label={title}>
{contents}
</optgroup>
);
} else {
// HTML does not support nested optgroups, so we need to flatten the structure
return <>{contents}</>;
}
}
return ( return (
<> <>
{articleId && articleId > 1 && (
<button onClick={() => setArticleId(articleId - 1)}>prev</button>
)}
<select <select
value={articleId || undefined} value={articleId || undefined}
className={styles.articleSelector}
onChange={(e) => { onChange={(e) => {
const id = parseInt(e.currentTarget.value); const id = parseInt(e.target.value);
setArticleId(id); setArticleId(id);
}} }}
> >
{articleIds.map((id) => ( {toc.map((div) => renderDivision(div))}
<option key={id} value={id}>
Article {id}
</option>
))}
</select> </select>
{articleId && articleId < articleIds[articleIds.length - 1] && (
<button onClick={() => setArticleId(articleId + 1)}>next</button>
)}
</> </>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import DOMPurify from "dompurify";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getArticle } from "../../lib/api"; import { getArticle } from "../../lib/api";
@@ -84,7 +85,7 @@ function Panel({ celexId, language, articleId }: PanelProps) {
<div <div
ref={articleRef} ref={articleRef}
lang={lang.substring(0, 2)} lang={lang.substring(0, 2)}
dangerouslySetInnerHTML={{ __html: data || "" }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(data) || "" }}
/> />
</div> </div>
); );

View File

@@ -3,10 +3,8 @@ import { Division } from "../../lib/types";
import useNavState from "../../store/navStore"; import useNavState from "../../store/navStore";
import styles from "./TOC.module.css"; import styles from "./TOC.module.css";
type TOC = Division[];
type TOCProps = { type TOCProps = {
toc: TOC; toc: Division[];
}; };
function TOC({ toc }: TOCProps) { function TOC({ toc }: TOCProps) {

14
frontend/src/hooks/toc.ts Normal file
View File

@@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { getToc } from "../lib/api";
import { Language } from "../lib/types";
import useNavState from "../store/navStore";
export const useTOC = () => {
const celexId = useNavState((state) => state.celexId);
const query = useQuery({
queryKey: ["toc", celexId],
queryFn: () => getToc(celexId!, Language.ENG),
enabled: !!celexId,
});
return query;
};

View File

@@ -1,5 +1,4 @@
import TOC from "../components/TOC/TOC"; import { Division, Language } from "./types";
import { Language } from "./types";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
@@ -23,10 +22,13 @@ async function getArticleIds(celexId: string): Promise<number[]> {
return await response.json(); return await response.json();
} }
async function getToc(celexId: string, language: Language): Promise<TOC> { async function getToc(
celexId: string,
language: Language
): Promise<Division[]> {
console.debug(`Fetching TOC for CELEX ID ${celexId}`); console.debug(`Fetching TOC for CELEX ID ${celexId}`);
const response = await fetch(`${API_URL}/${celexId}/toc/${language}`); const response = await fetch(`${API_URL}/${celexId}/toc/${language}`);
return await response.json(); return (await response.json()) as Division[];
} }
export { getArticle, getArticleIds, getToc }; export { getArticle, getArticleIds, getToc };

View File

@@ -68,9 +68,16 @@ def article_ids(celex_id: str, language: Language = Language.ENG):
@api_router.get("/{celex_id}/toc/{language}") @api_router.get("/{celex_id}/toc/{language}")
def toc(celex_id: str, language: Language = Language.ENG): def toc(celex_id: str, language: Language = Language.ENG):
def _extract_text(root: ET.Element, tag: str) -> str:
"""
Extract text from the given tag in the XML element.
"""
text = root.xpath(f"{tag}//text()")
return "".join(text) if text else ""
def _handle_division(division: ET.Element, level: int): def _handle_division(division: ET.Element, level: int):
title = ti_el[0] if (ti_el := division.xpath("TITLE/TI//text()")) else "" title = _extract_text(division, "TITLE/TI")
subtitle = sti_el[0] if (sti_el := division.xpath("TITLE/STI//text()")) else "" subtitle = _extract_text(division, "TITLE/STI")
subdivisions = [] subdivisions = []
for subdivision in division.xpath("DIVISION") or []: for subdivision in division.xpath("DIVISION") or []:
@@ -81,10 +88,9 @@ def toc(celex_id: str, language: Language = Language.ENG):
art_id = article.get("IDENTIFIER") art_id = article.get("IDENTIFIER")
if not art_id: if not art_id:
continue continue
art_title = ti_el[0] if (ti_el := article.xpath("TI.ART//text()")) else ""
art_subtitle = ( art_title = _extract_text(article, "TI.ART")
sti_el[0] if (sti_el := article.xpath("STI.ART//text()")) else "" art_subtitle = _extract_text(article, "STI.ART")
)
articles.append( articles.append(
{ {
"id": int(art_id.lstrip("0")), "id": int(art_id.lstrip("0")),