Paragraph-level links, preview tooltips
This commit is contained in:
64
frontend/__mocks__/zustand.ts
Normal file
64
frontend/__mocks__/zustand.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import type * as ZustandExportedTypes from "zustand";
|
||||||
|
export * from "zustand";
|
||||||
|
|
||||||
|
const { create: actualCreate, createStore: actualCreateStore } =
|
||||||
|
jest.requireActual<typeof ZustandExportedTypes>("zustand");
|
||||||
|
|
||||||
|
// a variable to hold reset functions for all stores declared in the app
|
||||||
|
export const storeResetFns = new Set<() => void>();
|
||||||
|
|
||||||
|
const createUncurried = <T>(
|
||||||
|
stateCreator: ZustandExportedTypes.StateCreator<T>
|
||||||
|
) => {
|
||||||
|
const store = actualCreate(stateCreator);
|
||||||
|
const initialState = store.getInitialState();
|
||||||
|
storeResetFns.add(() => {
|
||||||
|
store.setState(initialState, true);
|
||||||
|
});
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|
||||||
|
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||||
|
export const create = (<T>(
|
||||||
|
stateCreator: ZustandExportedTypes.StateCreator<T>
|
||||||
|
) => {
|
||||||
|
console.log("zustand create mock");
|
||||||
|
|
||||||
|
// to support curried version of create
|
||||||
|
return typeof stateCreator === "function"
|
||||||
|
? createUncurried(stateCreator)
|
||||||
|
: createUncurried;
|
||||||
|
}) as typeof ZustandExportedTypes.create;
|
||||||
|
|
||||||
|
const createStoreUncurried = <T>(
|
||||||
|
stateCreator: ZustandExportedTypes.StateCreator<T>
|
||||||
|
) => {
|
||||||
|
const store = actualCreateStore(stateCreator);
|
||||||
|
const initialState = store.getInitialState();
|
||||||
|
storeResetFns.add(() => {
|
||||||
|
store.setState(initialState, true);
|
||||||
|
});
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|
||||||
|
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||||
|
export const createStore = (<T>(
|
||||||
|
stateCreator: ZustandExportedTypes.StateCreator<T>
|
||||||
|
) => {
|
||||||
|
console.log("zustand createStore mock");
|
||||||
|
|
||||||
|
// to support curried version of createStore
|
||||||
|
return typeof stateCreator === "function"
|
||||||
|
? createStoreUncurried(stateCreator)
|
||||||
|
: createStoreUncurried;
|
||||||
|
}) as typeof ZustandExportedTypes.createStore;
|
||||||
|
|
||||||
|
// reset all stores after each test run
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
storeResetFns.forEach((resetFn) => {
|
||||||
|
resetFn();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.8",
|
||||||
"@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",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
"@jest/globals": "^29.7.0",
|
||||||
"@tanstack/eslint-plugin-query": "^5.73.3",
|
"@tanstack/eslint-plugin-query": "^5.73.3",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
@@ -1259,6 +1261,59 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
|
||||||
|
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
|
||||||
|
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.6.0",
|
||||||
|
"@floating-ui/utils": "^0.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react": {
|
||||||
|
"version": "0.27.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.8.tgz",
|
||||||
|
"integrity": "sha512-EQJ4Th328y2wyHR3KzOUOoTW2UKjFk53fmyahfwExnFQ8vnsMYqKc+fFPOkeYtj5tcp1DUMiNJ7BFhed7e9ONw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.1.2",
|
||||||
|
"@floating-ui/utils": "^0.2.9",
|
||||||
|
"tabbable": "^6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17.0.0",
|
||||||
|
"react-dom": ">=17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react-dom": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -8129,6 +8184,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tabbable": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/test-exclude": {
|
"node_modules/test-exclude": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.8",
|
||||||
"@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",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
"@jest/globals": "^29.7.0",
|
||||||
"@tanstack/eslint-plugin-query": "^5.73.3",
|
"@tanstack/eslint-plugin-query": "^5.73.3",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import CelexSelector from "./components/CelexSelector/CelexSelector";
|
|||||||
import { useTOC } from "./hooks/toc";
|
import { useTOC } from "./hooks/toc";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { numPanels, addPanel } = useUIStore();
|
const numPanels = useUIStore((state) => state.numPanels);
|
||||||
|
const addPanel = useUIStore((state) => state.addPanel);
|
||||||
const { data: toc, isPending, error } = useTOC();
|
const { data: toc, isPending, error } = useTOC();
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
|
|||||||
@@ -32,10 +32,6 @@ describe("ArticleSelector", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders a top-level division as an optgroup", () => {
|
test("renders a top-level division as an optgroup", () => {
|
||||||
const { getByRole } = render(<ArticleSelector toc={[mockDivision]} />);
|
const { getByRole } = render(<ArticleSelector toc={[mockDivision]} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, JSX } from "react";
|
import { Fragment, JSX } from "react";
|
||||||
import type { Division } from "../../lib/types";
|
import type { Division } from "../../lib/types";
|
||||||
import useNavState from "../../store/navStore";
|
import useNavStore from "../../store/navStore";
|
||||||
import styles from "./ArticleSelector.module.css";
|
import styles from "./ArticleSelector.module.css";
|
||||||
|
|
||||||
type ArticleSelectorProps = {
|
type ArticleSelectorProps = {
|
||||||
@@ -41,7 +41,8 @@ function renderDivision(div: Division): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ArticleSelector({ toc }: ArticleSelectorProps) {
|
function ArticleSelector({ toc }: ArticleSelectorProps) {
|
||||||
const { articleId, setArticleId } = useNavState();
|
const articleId = useNavStore.use.articleId();
|
||||||
|
const setArticleId = useNavStore.use.setArticleId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
import { examples } from "../../lib/examples";
|
import { examples } from "../../lib/examples";
|
||||||
import useNavState from "../../store/navStore";
|
import useNavStore from "../../store/navStore";
|
||||||
import CelexSelector from "./CelexSelector";
|
import CelexSelector from "./CelexSelector";
|
||||||
|
|
||||||
jest.mock("../../store/navStore");
|
|
||||||
|
|
||||||
describe("CelexSelector", () => {
|
describe("CelexSelector", () => {
|
||||||
const mockSetCelexId = jest.fn();
|
|
||||||
const mockSetArticleId = jest.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.mocked(useNavState).mockReturnValue({
|
|
||||||
celexId: "",
|
|
||||||
setCelexId: mockSetCelexId,
|
|
||||||
setArticleId: mockSetArticleId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the dropdown with options", () => {
|
it("renders the dropdown with options", () => {
|
||||||
render(<CelexSelector />);
|
const { getByLabelText, getAllByRole, getByRole } = render(
|
||||||
|
<CelexSelector />
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByLabelText("Select example:")).toBeInTheDocument();
|
expect(getByLabelText("Select example:")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
expect(getByRole("combobox")).toBeInTheDocument();
|
||||||
|
|
||||||
const options = screen.getAllByRole("option");
|
const options = getAllByRole("option");
|
||||||
expect(options).toHaveLength(examples.length);
|
expect(options).toHaveLength(examples.length);
|
||||||
for (const i in examples) {
|
for (const i in examples) {
|
||||||
expect(options[i]).toHaveValue(examples[i].id);
|
expect(options[i]).toHaveValue(examples[i].id);
|
||||||
@@ -32,28 +20,15 @@ describe("CelexSelector", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls setCelexId and setArticleId on selection change", () => {
|
it("calls setCelexId and setArticleId on selection change", async () => {
|
||||||
const celexId = "32016R0679";
|
const celexId = examples[2].id;
|
||||||
render(<CelexSelector />);
|
const { getByRole } = render(<CelexSelector />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox"), {
|
fireEvent.change(getByRole("combobox"), {
|
||||||
target: { value: celexId },
|
target: { value: celexId },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSetCelexId).toHaveBeenCalledWith(celexId);
|
expect(useNavStore.getState().celexId).toEqual(celexId);
|
||||||
expect(mockSetArticleId).toHaveBeenCalledWith(1);
|
expect(useNavStore.getState().articleId).toEqual(1);
|
||||||
});
|
|
||||||
|
|
||||||
it("sets the correct value in the dropdown based on celexId", () => {
|
|
||||||
const mockCelexId = "32024R1689";
|
|
||||||
jest.mocked(useNavState).mockReturnValue({
|
|
||||||
celexId: mockCelexId,
|
|
||||||
setCelexId: mockSetCelexId,
|
|
||||||
setArticleId: mockSetArticleId,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<CelexSelector />);
|
|
||||||
|
|
||||||
expect(screen.getByRole("combobox")).toHaveValue(mockCelexId);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { examples } from "../../lib/examples";
|
import { examples } from "../../lib/examples";
|
||||||
import useNavState from "../../store/navStore";
|
import useNavStore from "../../store/navStore";
|
||||||
|
|
||||||
function CelexSelector() {
|
function CelexSelector() {
|
||||||
const { celexId, setCelexId, setArticleId } = useNavState();
|
const celexId = useNavStore.use.celexId();
|
||||||
|
const setCelexId = useNavStore.use.setCelexId();
|
||||||
|
const setArticleId = useNavStore.use.setArticleId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { fireEvent, render } from "@testing-library/react";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { getArticle } from "../../lib/api";
|
import { getArticle } from "../../lib/api";
|
||||||
import { Language } from "../../lib/types";
|
import { Language } from "../../lib/types";
|
||||||
import useNavState from "../../store/navStore";
|
import useNavStore from "../../store/navStore";
|
||||||
import useUIStore from "../../store/uiStore";
|
import useUIStore from "../../store/uiStore";
|
||||||
import Panel from "./Panel";
|
import Panel from "./Panel";
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ describe("Panel Component", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.mocked(useNavState).mockReturnValue(mockNavState);
|
jest.mocked(useNavStore).mockReturnValue(mockNavState);
|
||||||
jest.mocked(useUIStore).mockReturnValue(mockUseUIStore);
|
jest.mocked(useUIStore).mockReturnValue(mockUseUIStore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import useUIStore from "../../store/uiStore";
|
|||||||
import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher";
|
import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher";
|
||||||
|
|
||||||
import { useArticle } from "../../hooks/useArticle";
|
import { useArticle } from "../../hooks/useArticle";
|
||||||
import useNavState from "../../store/navStore";
|
import useNavStore from "../../store/navStore";
|
||||||
import "../../styles/PanelContent.css";
|
import "../../styles/PanelContent.css";
|
||||||
|
import ParagraphPreview from "../ParagraphPreview/ParagraphPreview";
|
||||||
|
import { Tooltip, TooltipContent } from "../Tooltip/Tooltip";
|
||||||
import styles from "./Panel.module.css";
|
import styles from "./Panel.module.css";
|
||||||
|
|
||||||
type PanelProps = {
|
type PanelProps = {
|
||||||
@@ -18,9 +20,13 @@ function Panel({ language }: PanelProps) {
|
|||||||
|
|
||||||
const [lang, setLang] = useState(language || Language.ENG);
|
const [lang, setLang] = useState(language || Language.ENG);
|
||||||
const articleRef = useRef<HTMLDivElement>(null);
|
const articleRef = useRef<HTMLDivElement>(null);
|
||||||
const { articleId, celexId, setArticleId } = useNavState();
|
const { articleId, celexId, setArticleId } = useNavStore();
|
||||||
const { data, isPending, error } = useArticle(celexId, articleId, lang);
|
const { data, isPending, error } = useArticle(celexId, articleId, lang);
|
||||||
|
|
||||||
|
const [hoverArticleId, setHoverArticleId] = useState<number | null>(null);
|
||||||
|
const [hoverParagraphId, setHoverParagraphId] = useState<number | null>(null);
|
||||||
|
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const articleElement = articleRef.current;
|
const articleElement = articleRef.current;
|
||||||
if (!articleElement) return;
|
if (!articleElement) return;
|
||||||
@@ -32,6 +38,7 @@ function Panel({ language }: PanelProps) {
|
|||||||
crossRefs.forEach((link) => {
|
crossRefs.forEach((link) => {
|
||||||
const target = link.getAttribute("data-target");
|
const target = link.getAttribute("data-target");
|
||||||
const targetId = link.getAttribute("data-id");
|
const targetId = link.getAttribute("data-id");
|
||||||
|
const paragraphId = link.getAttribute("data-paragraph-id");
|
||||||
|
|
||||||
if (target && targetId) {
|
if (target && targetId) {
|
||||||
if (target === "article") {
|
if (target === "article") {
|
||||||
@@ -41,6 +48,17 @@ function Panel({ language }: PanelProps) {
|
|||||||
setArticleId(parseInt(targetId));
|
setArticleId(parseInt(targetId));
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
if (paragraphId) {
|
||||||
|
link.onmouseover = () => {
|
||||||
|
setHoverArticleId(parseInt(targetId));
|
||||||
|
setHoverParagraphId(parseInt(paragraphId));
|
||||||
|
setIsTooltipOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
link.onmouseout = () => {
|
||||||
|
setIsTooltipOpen(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("No target or ID found for link:", link);
|
console.warn("No target or ID found for link:", link);
|
||||||
@@ -83,18 +101,15 @@ function Panel({ language }: PanelProps) {
|
|||||||
// Cleanup event listeners
|
// Cleanup event listeners
|
||||||
return () => {
|
return () => {
|
||||||
console.log("Cleaning up event listeners");
|
console.log("Cleaning up event listeners");
|
||||||
|
// crossRefs.forEach((link) => {
|
||||||
|
// link.onmouseover = null;
|
||||||
|
// link.onmouseout = null;
|
||||||
|
// });
|
||||||
paragraphs.forEach((element) => {
|
paragraphs.forEach((element) => {
|
||||||
element.removeEventListener("click", handleClick(element));
|
element.removeEventListener("click", handleClick(element));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [
|
});
|
||||||
articleRef,
|
|
||||||
data,
|
|
||||||
celexId,
|
|
||||||
selectedParagraphId,
|
|
||||||
setSelectedParagraphId,
|
|
||||||
setArticleId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isPending) return "Loading...";
|
if (isPending) return "Loading...";
|
||||||
if (error) return "An error has occurred: " + error.message;
|
if (error) return "An error has occurred: " + error.message;
|
||||||
@@ -105,6 +120,20 @@ function Panel({ language }: PanelProps) {
|
|||||||
defaultLang={lang}
|
defaultLang={lang}
|
||||||
onChange={setLang}
|
onChange={setLang}
|
||||||
></LanguageSwitcher>
|
></LanguageSwitcher>
|
||||||
|
<Tooltip
|
||||||
|
open={isTooltipOpen}
|
||||||
|
placement="right-start"
|
||||||
|
onOpenChange={setIsTooltipOpen}
|
||||||
|
>
|
||||||
|
<TooltipContent>
|
||||||
|
<ParagraphPreview
|
||||||
|
celexId={celexId!}
|
||||||
|
articleId={hoverArticleId!}
|
||||||
|
paragraphId={hoverParagraphId!}
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<div
|
<div
|
||||||
ref={articleRef}
|
ref={articleRef}
|
||||||
lang={lang.substring(0, 2)}
|
lang={lang.substring(0, 2)}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useParagraph } from "../../hooks/useParagraph";
|
||||||
|
import { Language } from "../../lib/types";
|
||||||
|
|
||||||
|
type ParagraphPreviewProps = {
|
||||||
|
celexId: string;
|
||||||
|
articleId: number;
|
||||||
|
paragraphId: number;
|
||||||
|
lang: Language;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ParagraphPreview({
|
||||||
|
celexId,
|
||||||
|
articleId,
|
||||||
|
paragraphId,
|
||||||
|
lang,
|
||||||
|
}: ParagraphPreviewProps) {
|
||||||
|
const { data, isPending, error } = useParagraph(
|
||||||
|
celexId,
|
||||||
|
articleId,
|
||||||
|
paragraphId,
|
||||||
|
lang
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <p>Error loading paragraph: {error.message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: data }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParagraphPreview;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Division } from "../../lib/types";
|
import { Division } from "../../lib/types";
|
||||||
import useNavState from "../../store/navStore";
|
import useNavStore from "../../store/navStore";
|
||||||
import styles from "./TOC.module.css";
|
import styles from "./TOC.module.css";
|
||||||
|
|
||||||
type TOCProps = {
|
type TOCProps = {
|
||||||
@@ -8,7 +8,7 @@ type TOCProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function TOC({ toc }: TOCProps) {
|
function TOC({ toc }: TOCProps) {
|
||||||
const { articleId, setArticleId } = useNavState();
|
const { articleId, setArticleId } = useNavStore();
|
||||||
|
|
||||||
function containsArticle(division: Division, articleId: number): boolean {
|
function containsArticle(division: Division, articleId: number): boolean {
|
||||||
return division.content.some((c) => {
|
return division.content.some((c) => {
|
||||||
|
|||||||
10
frontend/src/components/Tooltip/Tooltip.module.css
Normal file
10
frontend/src/components/Tooltip/Tooltip.module.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.Tooltip {
|
||||||
|
background-color: #444;
|
||||||
|
color: white;
|
||||||
|
font-size: 90%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
163
frontend/src/components/Tooltip/Tooltip.tsx
Normal file
163
frontend/src/components/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { Placement } from "@floating-ui/react";
|
||||||
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
flip,
|
||||||
|
FloatingPortal,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
useDismiss,
|
||||||
|
useFloating,
|
||||||
|
useFocus,
|
||||||
|
useHover,
|
||||||
|
useInteractions,
|
||||||
|
useMergeRefs,
|
||||||
|
useRole,
|
||||||
|
} from "@floating-ui/react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import styles from "./Tooltip.module.css";
|
||||||
|
|
||||||
|
interface TooltipOptions {
|
||||||
|
initialOpen?: boolean;
|
||||||
|
placement?: Placement;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTooltip({
|
||||||
|
initialOpen = false,
|
||||||
|
placement = "top",
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: setControlledOpen,
|
||||||
|
}: TooltipOptions = {}) {
|
||||||
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen);
|
||||||
|
|
||||||
|
const open = controlledOpen ?? uncontrolledOpen;
|
||||||
|
const setOpen = setControlledOpen ?? setUncontrolledOpen;
|
||||||
|
|
||||||
|
const data = useFloating({
|
||||||
|
placement,
|
||||||
|
open,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
middleware: [
|
||||||
|
offset(5),
|
||||||
|
flip({
|
||||||
|
crossAxis: placement.includes("-"),
|
||||||
|
fallbackAxisSideDirection: "start",
|
||||||
|
padding: 5,
|
||||||
|
}),
|
||||||
|
shift({ padding: 5 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = data.context;
|
||||||
|
|
||||||
|
const hover = useHover(context, {
|
||||||
|
move: false,
|
||||||
|
enabled: controlledOpen == null,
|
||||||
|
});
|
||||||
|
const focus = useFocus(context, {
|
||||||
|
enabled: controlledOpen == null,
|
||||||
|
});
|
||||||
|
const dismiss = useDismiss(context);
|
||||||
|
const role = useRole(context, { role: "tooltip" });
|
||||||
|
|
||||||
|
const interactions = useInteractions([hover, focus, dismiss, role]);
|
||||||
|
|
||||||
|
return React.useMemo(
|
||||||
|
() => ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
...interactions,
|
||||||
|
...data,
|
||||||
|
}),
|
||||||
|
[open, setOpen, interactions, data]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextType = ReturnType<typeof useTooltip> | null;
|
||||||
|
|
||||||
|
const TooltipContext = React.createContext<ContextType>(null);
|
||||||
|
|
||||||
|
export const useTooltipContext = () => {
|
||||||
|
const context = React.useContext(TooltipContext);
|
||||||
|
|
||||||
|
if (context == null) {
|
||||||
|
throw new Error("Tooltip components must be wrapped in <Tooltip />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Tooltip({
|
||||||
|
children,
|
||||||
|
...options
|
||||||
|
}: { children: React.ReactNode } & TooltipOptions) {
|
||||||
|
// This can accept any props as options, e.g. `placement`,
|
||||||
|
// or other positioning options.
|
||||||
|
const tooltip = useTooltip(options);
|
||||||
|
return (
|
||||||
|
<TooltipContext.Provider value={tooltip}>
|
||||||
|
{children}
|
||||||
|
</TooltipContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TooltipTrigger = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.HTMLProps<HTMLElement> & { asChild?: boolean }
|
||||||
|
>(function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
|
||||||
|
const context = useTooltipContext();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const childrenRef = (children as any).ref;
|
||||||
|
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
|
||||||
|
|
||||||
|
// `asChild` allows the user to pass any element as the anchor
|
||||||
|
if (asChild && React.isValidElement(children)) {
|
||||||
|
return React.cloneElement(
|
||||||
|
children,
|
||||||
|
context.getReferenceProps({
|
||||||
|
ref,
|
||||||
|
...props,
|
||||||
|
...children.props,
|
||||||
|
"data-state": context.open ? "open" : "closed",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
// The user can style the trigger based on the state
|
||||||
|
data-state={context.open ? "open" : "closed"}
|
||||||
|
{...context.getReferenceProps(props)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLProps<HTMLDivElement>
|
||||||
|
>(function TooltipContent({ style, ...props }, propRef) {
|
||||||
|
const context = useTooltipContext();
|
||||||
|
const ref = useMergeRefs([context.refs.setFloating, propRef]);
|
||||||
|
|
||||||
|
if (!context.open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloatingPortal>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={styles.Tooltip}
|
||||||
|
style={{
|
||||||
|
...context.floatingStyles,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...context.getFloatingProps(props)}
|
||||||
|
/>
|
||||||
|
</FloatingPortal>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getToc } from "../lib/api";
|
import { getToc } from "../lib/api";
|
||||||
import { Language } from "../lib/types";
|
import { Language } from "../lib/types";
|
||||||
import useNavState from "../store/navStore";
|
import useNavStore from "../store/navStore";
|
||||||
|
|
||||||
export const useTOC = () => {
|
export const useTOC = () => {
|
||||||
const celexId = useNavState((state) => state.celexId);
|
const celexId = useNavStore((state) => state.celexId);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["toc", celexId],
|
queryKey: ["toc", celexId],
|
||||||
queryFn: () => getToc(celexId!, Language.ENG),
|
queryFn: () => getToc(celexId!, Language.ENG),
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import useNavState from "../store/navStore";
|
import useNavStore from "../store/navStore";
|
||||||
|
|
||||||
export const useUrlSync = () => {
|
export const useUrlSync = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { celexId: paramCelexId, articleId: paramArticleId } = useParams();
|
const { celexId: paramCelexId, articleId: paramArticleId } = useParams();
|
||||||
const { articleId, celexId, setArticleId, setCelexId } = useNavState();
|
|
||||||
|
const celexId = useNavStore.use.celexId();
|
||||||
|
const setCelexId = useNavStore.use.setCelexId();
|
||||||
|
const articleId = useNavStore.use.articleId();
|
||||||
|
const setArticleId = useNavStore.use.setArticleId();
|
||||||
|
|
||||||
// Effect to read from URL when URL changes
|
// Effect to read from URL when URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
16
frontend/src/hooks/useParagraph.ts
Normal file
16
frontend/src/hooks/useParagraph.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getParagraph } from "../lib/api";
|
||||||
|
import { Language } from "../lib/types";
|
||||||
|
|
||||||
|
export const useParagraph = (
|
||||||
|
celexId: string | null,
|
||||||
|
articleId: number | null,
|
||||||
|
paragraphId: number | null,
|
||||||
|
lang: Language
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["paragraph", celexId, articleId, paragraphId, lang],
|
||||||
|
queryFn: () => getParagraph(celexId!, articleId!, paragraphId!, lang),
|
||||||
|
enabled: !!celexId && !!articleId && !!paragraphId,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -24,6 +24,21 @@ async function getArticle(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getParagraph(
|
||||||
|
celexId: string,
|
||||||
|
article: number,
|
||||||
|
paragraph: number,
|
||||||
|
language: string
|
||||||
|
): Promise<string> {
|
||||||
|
console.debug(
|
||||||
|
`Fetching article ${article} paragraph ${paragraph} for CELEX ID ${celexId} in ${language} language`
|
||||||
|
);
|
||||||
|
const response = await axios.get<string>(
|
||||||
|
`${celexId}/articles/${article}/${paragraph}/${language}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
async function getArticleIds(celexId: string): Promise<number[]> {
|
async function getArticleIds(celexId: string): Promise<number[]> {
|
||||||
console.debug(`Fetching article list for CELEX ID ${celexId}`);
|
console.debug(`Fetching article list for CELEX ID ${celexId}`);
|
||||||
const response = await axios.get<number[]>(`${celexId}/articles`);
|
const response = await axios.get<number[]>(`${celexId}/articles`);
|
||||||
@@ -39,4 +54,4 @@ async function getToc(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getArticle, getArticleIds, getToc };
|
export { getArticle, getArticleIds, getParagraph, getToc };
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import App from "../App";
|
import App from "../App";
|
||||||
import { useUrlSync } from "../hooks/urlSync";
|
import { useUrlSync } from "../hooks/urlSync";
|
||||||
import useNavState from "../store/navStore";
|
import useNavStore from "../store/navStore";
|
||||||
|
|
||||||
function MainView() {
|
function MainView() {
|
||||||
useUrlSync();
|
useUrlSync();
|
||||||
const celexId = useNavState((state) => state.celexId);
|
const celexId = useNavStore((state) => state.celexId);
|
||||||
|
|
||||||
if (!celexId) {
|
if (!celexId) {
|
||||||
return <div>Error: No CELEX ID provided</div>;
|
return <div>Error: No CELEX ID provided</div>;
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { create } from "zustand";
|
import { create, StateCreator } from "zustand";
|
||||||
|
import { createSelectors } from "../util/zustand";
|
||||||
|
|
||||||
interface NavState {
|
export type NavStore = {
|
||||||
celexId: string | null;
|
celexId: string | null;
|
||||||
articleId: number | null;
|
articleId: number | null;
|
||||||
setCelexId: (celexId: string) => void;
|
setCelexId: (celexId: string) => void;
|
||||||
setArticleId: (articleId: number | null) => void;
|
setArticleId: (articleId: number | null) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const useNavState = create<NavState>((set) => ({
|
const navStoreCreator: StateCreator<NavStore> = (set) => ({
|
||||||
celexId: null,
|
celexId: "",
|
||||||
articleId: null,
|
articleId: 1,
|
||||||
setCelexId: (celexId) => set({ celexId }),
|
setCelexId: (celexId) => set({ celexId }),
|
||||||
setArticleId: (articleId) => set({ articleId }),
|
setArticleId: (articleId) => set({ articleId }),
|
||||||
}));
|
});
|
||||||
|
|
||||||
export default useNavState;
|
const useNavStore = createSelectors(create<NavStore>()(navStoreCreator));
|
||||||
|
export default useNavStore;
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ interface UIState {
|
|||||||
const useUIStore = create<UIState>((set) => ({
|
const useUIStore = create<UIState>((set) => ({
|
||||||
numPanels: 1,
|
numPanels: 1,
|
||||||
selectedParagraphId: null,
|
selectedParagraphId: null,
|
||||||
|
|
||||||
addPanel: () => set((state) => ({ numPanels: state.numPanels + 1 })),
|
addPanel: () => set((state) => ({ numPanels: state.numPanels + 1 })),
|
||||||
removePanel: () =>
|
removePanel: () =>
|
||||||
set((state) => ({ numPanels: Math.max(state.numPanels - 1, 1) })),
|
set((state) => ({ numPanels: Math.max(state.numPanels - 1, 1) })),
|
||||||
setSelectedParagraphId: (selectedParagraphId: string | null) =>
|
setSelectedParagraphId: (selectedParagraphId) => set({ selectedParagraphId }),
|
||||||
set({ selectedParagraphId }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useUIStore;
|
export default useUIStore;
|
||||||
|
|||||||
18
frontend/src/util/zustand.ts
Normal file
18
frontend/src/util/zustand.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { StoreApi, UseBoundStore } from "zustand";
|
||||||
|
|
||||||
|
type WithSelectors<S> = S extends { getState: () => infer T }
|
||||||
|
? S & { use: { [K in keyof T]: () => T[K] } }
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
|
||||||
|
_store: S
|
||||||
|
) => {
|
||||||
|
const store = _store as WithSelectors<typeof _store>;
|
||||||
|
store.use = {};
|
||||||
|
for (const k of Object.keys(store.getState())) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
};
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
|
|
||||||
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
||||||
},
|
},
|
||||||
"include": ["src", "./jest.setup.ts"]
|
"include": ["src", "./jest.setup.ts", "__mocks__"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import html
|
|||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Union
|
from typing import Literal, Optional, Union
|
||||||
|
|
||||||
import lxml.etree
|
import lxml.etree
|
||||||
from lxml import etree as ET
|
from lxml import etree as ET
|
||||||
@@ -29,9 +29,10 @@ def text_content(el: lxml.etree.Element) -> str:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CrossReference:
|
class CrossReference:
|
||||||
id: str
|
target: Literal["article", "annex"]
|
||||||
text: str
|
text: str
|
||||||
target: str
|
id: str
|
||||||
|
paragraph: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossReference]:
|
def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossReference]:
|
||||||
@@ -69,8 +70,8 @@ def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossRefer
|
|||||||
# Also, match only at word boundaries to prevent partial matches
|
# Also, match only at word boundaries to prevent partial matches
|
||||||
parts = PATTERN_PARTS[language]
|
parts = PATTERN_PARTS[language]
|
||||||
patterns = {
|
patterns = {
|
||||||
"article": rf"\b{parts["article"]}\s+(?P<art_num>\d+){parts["exclusion"]}\b",
|
"article": rf"\b{parts["article"]}\s+(?P<art_num>\d+)(?:[(](?P<parag_num>\d+)[)])?(?:{parts["exclusion"]})",
|
||||||
"annex": rf"\b{parts["annex"]}\s+(?P<annex_num>[DILMVX]+){parts["exclusion"]}\b",
|
"annex": rf"\b{parts["annex"]}\s+(?P<annex_num>[DILMVX]+)(?:{parts["exclusion"]})",
|
||||||
}
|
}
|
||||||
for key, pattern in patterns.items():
|
for key, pattern in patterns.items():
|
||||||
matches = re.finditer(pattern, text, flags=re.IGNORECASE)
|
matches = re.finditer(pattern, text, flags=re.IGNORECASE)
|
||||||
@@ -78,13 +79,54 @@ def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossRefer
|
|||||||
crossref_id = (
|
crossref_id = (
|
||||||
match.group("art_num") if key == "article" else match.group("annex_num")
|
match.group("art_num") if key == "article" else match.group("annex_num")
|
||||||
)
|
)
|
||||||
|
parag_num = match.groupdict().get("parag_num")
|
||||||
crossref_text = match.group(0)
|
crossref_text = match.group(0)
|
||||||
crossrefs.append(
|
crossrefs.append(
|
||||||
CrossReference(id=crossref_id, text=crossref_text, target=key)
|
CrossReference(
|
||||||
|
target=key,
|
||||||
|
id=crossref_id,
|
||||||
|
paragraph=parag_num,
|
||||||
|
text=crossref_text,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return crossrefs
|
return crossrefs
|
||||||
|
|
||||||
|
|
||||||
|
def extract_article(doc: ET.ElementBase, article_id: int) -> ET.ElementBase | None:
|
||||||
|
"""Extract a specific article from a Formex document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc: The XML document to extract from.
|
||||||
|
article_id: The article number.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The extracted article element.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Use XPath to find the specific article
|
||||||
|
xpath = f".//ARTICLE[@IDENTIFIER='{article_id:03d}']"
|
||||||
|
return doc.xpath(xpath)[0] if doc.xpath(xpath) else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_paragraph(
|
||||||
|
doc: ET.ElementBase, article_id: int, paragraph_id: int
|
||||||
|
) -> ET.ElementBase | None:
|
||||||
|
"""Extract a specific paragraph from an article in a Formex document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc: The XML document to extract from.
|
||||||
|
article_id: The article number.
|
||||||
|
paragraph_id: The paragraph number.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The extracted paragraph element.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Use XPath to find the specific paragraph
|
||||||
|
xpath = f".//PARAG[@IDENTIFIER='{article_id:03d}.{paragraph_id:03d}']"
|
||||||
|
return doc.xpath(xpath)[0] if doc.xpath(xpath) else None
|
||||||
|
|
||||||
|
|
||||||
class FormexArticleConverter:
|
class FormexArticleConverter:
|
||||||
"""Converts Formex XML <ARTICLE> elements to semantic HTML5."""
|
"""Converts Formex XML <ARTICLE> elements to semantic HTML5."""
|
||||||
|
|
||||||
@@ -136,7 +178,7 @@ class FormexArticleConverter:
|
|||||||
# Replace the cross-reference text with a link
|
# Replace the cross-reference text with a link
|
||||||
text = text.replace(
|
text = text.replace(
|
||||||
xref.text,
|
xref.text,
|
||||||
f'<a href="" data-target="{xref.target}" data-id="{xref.id}" class="cross-ref">{xref.text}</a>',
|
f'<a href="" data-target="{xref.target}" data-id="{xref.id}" data-paragraph-id="{xref.paragraph or ''}" class="cross-ref">{xref.text}</a>',
|
||||||
)
|
)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@@ -418,10 +460,13 @@ class FormexArticleConverter:
|
|||||||
article_subtitle = self._convert_btx(sti_art) if sti_art is not None else ""
|
article_subtitle = self._convert_btx(sti_art) if sti_art is not None else ""
|
||||||
|
|
||||||
# Build the header section
|
# Build the header section
|
||||||
|
if article_title and article_subtitle:
|
||||||
header = f'<header><h3 class="article-title">{article_title}</h3>'
|
header = f'<header><h3 class="article-title">{article_title}</h3>'
|
||||||
if article_subtitle:
|
if article_subtitle:
|
||||||
header += f'<h4 class="article-subtitle">{article_subtitle}</h4>'
|
header += f'<h4 class="article-subtitle">{article_subtitle}</h4>'
|
||||||
header += "</header>"
|
header += "</header>"
|
||||||
|
else:
|
||||||
|
header = ""
|
||||||
|
|
||||||
# Process the content based on what's present
|
# Process the content based on what's present
|
||||||
content = ""
|
content = ""
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import lxml.etree as ET
|
|||||||
from fastapi import APIRouter, FastAPI, Response
|
from fastapi import APIRouter, FastAPI, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from formex_viewer.formex4 import FormexArticleConverter
|
from formex_viewer.formex4 import (
|
||||||
|
FormexArticleConverter,
|
||||||
|
extract_article,
|
||||||
|
extract_paragraph,
|
||||||
|
)
|
||||||
from formex_viewer.main import (
|
from formex_viewer.main import (
|
||||||
CellarClient,
|
CellarClient,
|
||||||
CellarIdentifier,
|
CellarIdentifier,
|
||||||
@@ -121,21 +125,46 @@ def toc(celex_id: str, language: Language = Language.ENG):
|
|||||||
|
|
||||||
|
|
||||||
@api_router.get("/{celex_id}/articles/{article_id}/{language}")
|
@api_router.get("/{celex_id}/articles/{article_id}/{language}")
|
||||||
def article(celex_id: str, article_id: int, language: Language = Language.ENG):
|
def article(
|
||||||
|
celex_id: str,
|
||||||
|
article_id: int,
|
||||||
|
language: Language = Language.ENG,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Fetch an article from the server.
|
Fetch an article from the server.
|
||||||
"""
|
"""
|
||||||
xml = _get_fmx4_data(celex_id, language)
|
xml = _get_fmx4_data(celex_id, language)
|
||||||
|
article = extract_article(xml, article_id=article_id)
|
||||||
|
|
||||||
|
if article is None:
|
||||||
|
return Response(
|
||||||
|
"Article not found",
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
article_xpath = "//ARTICLE"
|
|
||||||
articles = xml.xpath(article_xpath)
|
|
||||||
for article in articles:
|
|
||||||
num = article.get("IDENTIFIER").lstrip("0")
|
|
||||||
if num == str(article_id):
|
|
||||||
return Response(
|
return Response(
|
||||||
FormexArticleConverter(language=language).convert_article(article),
|
FormexArticleConverter(language=language).convert_article(article),
|
||||||
media_type="text/html",
|
media_type="text/html",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/{celex_id}/articles/{article_id}/{parag_id}/{language}")
|
||||||
|
def paragraph(
|
||||||
|
celex_id: str,
|
||||||
|
article_id: int,
|
||||||
|
parag_id: int,
|
||||||
|
language: Language = Language.ENG,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fetch a paragraph within an article from the server.
|
||||||
|
"""
|
||||||
|
xml = _get_fmx4_data(celex_id, language)
|
||||||
|
parag = extract_paragraph(xml, article_id=article_id, paragraph_id=parag_id)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
FormexArticleConverter(language=language).convert_article(parag),
|
||||||
|
media_type="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api")
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|||||||
Reference in New Issue
Block a user