From 0d32e26c786732d22decef687195e9a5d84f7637 Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Tue, 15 Oct 2024 17:07:33 +0200 Subject: [PATCH 01/23] Add creation timestamp to version --- docat/docat/models.py | 2 + docat/docat/utils.py | 9 +++++ docat/tests/test_hide_show.py | 27 +++++++++---- docat/tests/test_project.py | 70 ++++++++++++++++++++++++--------- docat/tests/test_upload_icon.py | 8 ++-- 5 files changed, 87 insertions(+), 29 deletions(-) diff --git a/docat/docat/models.py b/docat/docat/models.py index 155529b1b..5f15c3da4 100644 --- a/docat/docat/models.py +++ b/docat/docat/models.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import datetime from pydantic import BaseModel @@ -19,6 +20,7 @@ class ClaimResponse(ApiResponse): class ProjectVersion(BaseModel): name: str + timestamp: datetime tags: list[str] hidden: bool diff --git a/docat/docat/utils.py b/docat/docat/utils.py index a64245abc..22782ed14 100644 --- a/docat/docat/utils.py +++ b/docat/docat/utils.py @@ -5,6 +5,7 @@ import hashlib import os import shutil +from datetime import datetime from pathlib import Path from zipfile import ZipFile, ZipInfo @@ -149,6 +150,13 @@ def get_all_projects(upload_folder_path: Path, include_hidden: bool) -> Projects return Projects(projects=projects) +def get_version_timestamp(version_folder: Path) -> datetime: + """ + Returns the timestamp of a version + """ + return datetime.fromtimestamp(version_folder.stat().st_ctime) + + def get_project_details(upload_folder_path: Path, project_name: str, include_hidden: bool) -> ProjectDetail | None: """ Returns all versions and tags for a project. @@ -173,6 +181,7 @@ def should_include(name: str) -> bool: ProjectVersion( name=str(x.relative_to(docs_folder)), tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x], + timestamp=get_version_timestamp(x), hidden=(docs_folder / x.name / ".hidden").exists(), ) for x in docs_folder.iterdir() diff --git a/docat/tests/test_hide_show.py b/docat/tests/test_hide_show.py index ee451dae3..de3e836d3 100644 --- a/docat/tests/test_hide_show.py +++ b/docat/tests/test_hide_show.py @@ -1,10 +1,12 @@ import io +from datetime import datetime from unittest.mock import patch import docat.app as docat -def test_hide(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_hide(_, client_with_claimed_project): """ Tests that the version is marked as hidden when getting the details after hiding """ @@ -19,7 +21,7 @@ def test_hide(client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", - "versions": [{"name": "1.0.0", "tags": [], "hidden": False}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } # hide the version @@ -36,7 +38,8 @@ def test_hide(client_with_claimed_project): } -def test_hide_only_version_not_listed_in_projects(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_hide_only_version_not_listed_in_projects(_, client_with_claimed_project): """ Test that the project is not listed in the projects endpoint when the only version is hidden """ @@ -50,7 +53,13 @@ def test_hide_only_version_not_listed_in_projects(client_with_claimed_project): projects_response = client_with_claimed_project.get("/api/projects") assert projects_response.status_code == 200 assert projects_response.json() == { - "projects": [{"name": "some-project", "logo": False, "versions": [{"name": "1.0.0", "tags": [], "hidden": False}]}], + "projects": [ + { + "name": "some-project", + "logo": False, + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], + } + ], } # hide the only version @@ -186,7 +195,8 @@ def test_hide_fails_invalid_token(client_with_claimed_project): open_file_mock.assert_not_called() -def test_show(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_show(_, client_with_claimed_project): """ Tests that the version is no longer marked as hidden after requesting show. """ @@ -219,7 +229,7 @@ def test_show(client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", - "versions": [{"name": "1.0.0", "tags": [], "hidden": False}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } @@ -353,7 +363,8 @@ def test_show_fails_invalid_token(client_with_claimed_project): remove_file_mock.assert_not_called() -def test_hide_and_show_with_tag(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_hide_and_show_with_tag(_, client_with_claimed_project): """ Tests that the version is no longer marked as hidden after requesting show on a tag. """ @@ -391,5 +402,5 @@ def test_hide_and_show_with_tag(client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", - "versions": [{"name": "1.0.0", "tags": ["latest"], "hidden": False}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}], } diff --git a/docat/tests/test_project.py b/docat/tests/test_project.py index 0874455a0..d75184508 100644 --- a/docat/tests/test_project.py +++ b/docat/tests/test_project.py @@ -1,4 +1,5 @@ import io +from datetime import datetime from unittest.mock import patch import httpx @@ -11,7 +12,8 @@ client = TestClient(docat.app) -def test_project_api(temp_project_version): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_project_api(_, temp_project_version): docs = temp_project_version("project", "1.0") docs = temp_project_version("different-project", "1.0") @@ -25,14 +27,14 @@ def test_project_api(temp_project_version): "name": "different-project", "logo": False, "versions": [ - {"name": "1.0", "tags": ["latest"], "hidden": False}, + {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, ], }, { "name": "project", "logo": False, "versions": [ - {"name": "1.0", "tags": ["latest"], "hidden": False}, + {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, ], }, ] @@ -46,7 +48,8 @@ def test_project_api_without_any_projects(): assert response.json() == {"projects": []} -def test_project_details_api(temp_project_version): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_project_details_api(_, temp_project_version): project = "project" docs = temp_project_version(project, "1.0") symlink_to_latest = docs / project / "latest" @@ -56,7 +59,10 @@ def test_project_details_api(temp_project_version): response = client.get(f"/api/projects/{project}") assert response.status_code == httpx.codes.OK - assert response.json() == {"name": "project", "versions": [{"name": "1.0", "tags": ["latest"], "hidden": False}]} + assert response.json() == { + "name": "project", + "versions": [{"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}], + } def test_project_details_api_with_a_project_that_does_not_exist(): @@ -66,7 +72,8 @@ def test_project_details_api_with_a_project_that_does_not_exist(): assert response.json() == {"message": "Project i-do-not-exist does not exist"} -def test_get_project_details_with_hidden_versions(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_get_project_details_with_hidden_versions(_, client_with_claimed_project): """ Make sure that get_project_details works when include_hidden is set to True. """ @@ -78,7 +85,9 @@ def test_get_project_details_with_hidden_versions(client_with_claimed_project): # check detected before hiding details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) - assert details == ProjectDetail(name="some-project", versions=[ProjectVersion(name="1.0.0", tags=[], hidden=False)]) + assert details == ProjectDetail( + name="some-project", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)] + ) # hide the version hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"}) @@ -87,10 +96,13 @@ def test_get_project_details_with_hidden_versions(client_with_claimed_project): # check hidden details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) - assert details == ProjectDetail(name="some-project", versions=[ProjectVersion(name="1.0.0", tags=[], hidden=True)]) + assert details == ProjectDetail( + name="some-project", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=True)] + ) -def test_project_details_without_hidden_versions(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_project_details_without_hidden_versions(_, client_with_claimed_project): """ Make sure that project_details works when include_hidden is set to False. """ @@ -102,7 +114,9 @@ def test_project_details_without_hidden_versions(client_with_claimed_project): # check detected before hiding details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False) - assert details == ProjectDetail(name="some-project", versions=[ProjectVersion(name="1.0.0", tags=[], hidden=False)]) + assert details == ProjectDetail( + name="some-project", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)] + ) # hide the version hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"}) @@ -114,7 +128,8 @@ def test_project_details_without_hidden_versions(client_with_claimed_project): assert details == ProjectDetail(name="some-project", versions=[]) -def test_include_hidden_parameter_for_get_projects(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_include_hidden_parameter_for_get_projects(_, client_with_claimed_project): """ Make sure that include_hidden has the desired effect on the /api/projects endpoint. """ @@ -128,14 +143,26 @@ def test_include_hidden_parameter_for_get_projects(client_with_claimed_project): get_projects_response = client_with_claimed_project.get("/api/projects") assert get_projects_response.status_code == 200 assert get_projects_response.json() == { - "projects": [{"name": "some-project", "logo": False, "versions": [{"name": "1.0.0", "tags": [], "hidden": False}]}] + "projects": [ + { + "name": "some-project", + "logo": False, + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], + } + ] } # check include_hidden=True get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true") assert get_projects_response.status_code == 200 assert get_projects_response.json() == { - "projects": [{"name": "some-project", "logo": False, "versions": [{"name": "1.0.0", "tags": [], "hidden": False}]}] + "projects": [ + { + "name": "some-project", + "logo": False, + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], + } + ] } # hide the version @@ -152,11 +179,18 @@ def test_include_hidden_parameter_for_get_projects(client_with_claimed_project): get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true") assert get_projects_response.status_code == 200 assert get_projects_response.json() == { - "projects": [{"name": "some-project", "logo": False, "versions": [{"name": "1.0.0", "tags": [], "hidden": True}]}] + "projects": [ + { + "name": "some-project", + "logo": False, + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}], + } + ] } -def test_include_hidden_parameter_for_get_project_details(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed_project): """ Make sure that include_hidden has the desired effect on the /api/project/{project} endpoint. """ @@ -171,7 +205,7 @@ def test_include_hidden_parameter_for_get_project_details(client_with_claimed_pr assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", - "versions": [{"name": "1.0.0", "tags": [], "hidden": False}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } # check include_hidden=True @@ -179,7 +213,7 @@ def test_include_hidden_parameter_for_get_project_details(client_with_claimed_pr assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", - "versions": [{"name": "1.0.0", "tags": [], "hidden": False}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } # hide the version @@ -200,5 +234,5 @@ def test_include_hidden_parameter_for_get_project_details(client_with_claimed_pr assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", - "versions": [{"name": "1.0.0", "tags": [], "hidden": True}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}], } diff --git a/docat/tests/test_upload_icon.py b/docat/tests/test_upload_icon.py index 95da7f8ce..275b7afd7 100644 --- a/docat/tests/test_upload_icon.py +++ b/docat/tests/test_upload_icon.py @@ -1,5 +1,6 @@ import base64 import io +from datetime import datetime from unittest.mock import call, patch import docat.app as docat @@ -143,7 +144,8 @@ def test_icon_upload_fails_no_image(client_with_claimed_project): assert copyfileobj_mock.mock_calls == [] -def test_get_project_recongizes_icon(client_with_claimed_project): +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +def test_get_project_recongizes_icon(_, client_with_claimed_project): """ get_projects should return true, if the project has an icon """ @@ -160,7 +162,7 @@ def test_get_project_recongizes_icon(client_with_claimed_project): { "name": "some-project", "logo": False, - "versions": [{"name": "1.0.0", "tags": [], "hidden": False}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] } @@ -178,7 +180,7 @@ def test_get_project_recongizes_icon(client_with_claimed_project): { "name": "some-project", "logo": True, - "versions": [{"name": "1.0.0", "tags": [], "hidden": False}], + "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] } From 71f6a112da1937b65300fdbffff54af81abb6afa Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Tue, 15 Oct 2024 17:10:09 +0200 Subject: [PATCH 02/23] Redesign home page --- web/src/components/ClaimButton.tsx | 29 --- web/src/components/DeleteButton.tsx | 29 --- web/src/components/FavoriteStar.tsx | 4 +- web/src/components/Footer.tsx | 9 +- web/src/components/Header.tsx | 9 +- web/src/components/NavigationTitle.tsx | 1 - web/src/components/PageLayout.tsx | 3 +- web/src/components/Project.tsx | 94 ++++++---- web/src/components/ProjectList.tsx | 3 +- web/src/components/SearchBar.tsx | 96 +++++++--- web/src/components/StyledForm.tsx | 1 - web/src/components/UploadButton.tsx | 29 --- web/src/index.css | 3 +- web/src/models/ProjectDetails.ts | 4 +- web/src/pages/Help.tsx | 6 +- web/src/pages/Home.tsx | 167 +++++++++++------- web/src/repositories/ProjectRepository.ts | 9 +- web/src/style/components/Footer.module.css | 35 +++- web/src/style/components/Header.module.css | 6 +- .../components/NavigationTitle.module.css | 2 - web/src/style/components/Project.module.css | 48 +++-- .../style/components/ProjectList.module.css | 29 +-- .../style/components/StyledForm.module.css | 2 - web/src/style/pages/Help.module.css | 1 + web/src/style/pages/Home.module.css | 16 ++ .../repositories/ProjectRepository.test.ts | 37 ++-- 26 files changed, 355 insertions(+), 317 deletions(-) delete mode 100644 web/src/components/ClaimButton.tsx delete mode 100644 web/src/components/DeleteButton.tsx delete mode 100644 web/src/components/UploadButton.tsx diff --git a/web/src/components/ClaimButton.tsx b/web/src/components/ClaimButton.tsx deleted file mode 100644 index 4818877c0..000000000 --- a/web/src/components/ClaimButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' -import { Link } from 'react-router-dom' -import { Lock } from '@mui/icons-material' -import { Tooltip } from '@mui/material' - -import styles from './../style/components/ControlButtons.module.css' - -interface Props { - isSingleButton?: boolean -} - -export default function ClaimButton(props: Props): JSX.Element { - return ( - <> - - - - - - - ) -} diff --git a/web/src/components/DeleteButton.tsx b/web/src/components/DeleteButton.tsx deleted file mode 100644 index 8a5ed4d66..000000000 --- a/web/src/components/DeleteButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Link } from 'react-router-dom' -import { Delete } from '@mui/icons-material' -import { Tooltip } from '@mui/material' -import React from 'react' - -import styles from './../style/components/ControlButtons.module.css' - -interface Props { - isSingleButton?: boolean -} - -export default function DeleteButton(props: Props): JSX.Element { - return ( - <> - - - - - - - ) -} diff --git a/web/src/components/FavoriteStar.tsx b/web/src/components/FavoriteStar.tsx index ba022d7fd..08e1d30e8 100644 --- a/web/src/components/FavoriteStar.tsx +++ b/web/src/components/FavoriteStar.tsx @@ -1,5 +1,5 @@ import { Star, StarOutline } from '@mui/icons-material' -import React, { useState } from 'react' +import { useState } from 'react' import ProjectRepository from '../repositories/ProjectRepository' interface Props { @@ -24,7 +24,7 @@ export default function FavoriteStar(props: Props): JSX.Element { return ( ) diff --git a/web/src/components/Footer.tsx b/web/src/components/Footer.tsx index 7bcfb30c2..f0cbb9f60 100644 --- a/web/src/components/Footer.tsx +++ b/web/src/components/Footer.tsx @@ -1,17 +1,18 @@ import { Link } from 'react-router-dom' import styles from './../style/components/Footer.module.css' -import React from 'react' export default function Footer(): JSX.Element { return (
- Help + HELP
- Docat Version{' '} - {import.meta.env.VITE_DOCAT_VERSION ?? 'unknown'} + + VERSION{' '} + {import.meta.env.VITE_DOCAT_VERSION ?? 'unknown'} +
) diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 0173868fa..505165fa5 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,17 +1,13 @@ -import React, { useState } from 'react' +import { useState } from 'react' import { Link } from 'react-router-dom' -import SearchBar from './SearchBar' import { useConfig } from '../data-providers/ConfigDataProvider' import docatLogo from '../assets/logo.png' import styles from './../style/components/Header.module.css' -interface Props { - showSearchBar?: boolean -} -export default function Header(props: Props): JSX.Element { +export default function Header(): JSX.Element { const defaultHeader = ( <> docat logo @@ -30,7 +26,6 @@ export default function Header(props: Props): JSX.Element { return (
{header} - {props.showSearchBar !== false && }
) } diff --git a/web/src/components/NavigationTitle.tsx b/web/src/components/NavigationTitle.tsx index 5de13a09e..87d3778d5 100644 --- a/web/src/components/NavigationTitle.tsx +++ b/web/src/components/NavigationTitle.tsx @@ -1,6 +1,5 @@ import { ArrowBackIos } from '@mui/icons-material' import { Link } from 'react-router-dom' -import React from 'react' import styles from './../style/components/NavigationTitle.module.css' diff --git a/web/src/components/PageLayout.tsx b/web/src/components/PageLayout.tsx index f58644cbb..d9fb00ec4 100644 --- a/web/src/components/PageLayout.tsx +++ b/web/src/components/PageLayout.tsx @@ -2,7 +2,6 @@ import styles from './../style/components/PageLayout.module.css' import Footer from './Footer' import Header from './Header' import NavigationTitle from './NavigationTitle' -import React from 'react' interface Props { title: string @@ -14,7 +13,7 @@ interface Props { export default function PageLayout(props: Props): JSX.Element { return ( <> -
+
{props.children} diff --git a/web/src/components/Project.tsx b/web/src/components/Project.tsx index 9a9505f4a..2dfb70797 100644 --- a/web/src/components/Project.tsx +++ b/web/src/components/Project.tsx @@ -1,64 +1,88 @@ -import React from 'react' import { Link } from 'react-router-dom' +import { type Project as ProjectType } from '../models/ProjectsResponse' import ProjectRepository from '../repositories/ProjectRepository' import styles from './../style/components/Project.module.css' -import { type Project as ProjectType } from '../models/ProjectsResponse' -import FavoriteStar from './FavoriteStar' import { Tooltip } from '@mui/material' +import FavoriteStar from './FavoriteStar' interface Props { project: ProjectType onFavoriteChanged: () => void } +function timeSince(date: Date) { + const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); + let interval = seconds / 31536000; + + if (interval > 1) { + return Math.floor(interval) + " years"; + } + interval = seconds / 2592000; + if (interval > 1) { + return Math.floor(interval) + " months"; + } + interval = seconds / 86400; + if (interval > 1) { + return Math.floor(interval) + " days"; + } + interval = seconds / 3600; + if (interval > 1) { + return Math.floor(interval) + " hours"; + } + interval = seconds / 60; + if (interval > 1) { + return Math.floor(interval) + " minutes"; + } + return Math.floor(seconds) + " seconds"; +} + export default function Project(props: Props): JSX.Element { + const latestVersion = ProjectRepository.getLatestVersion(props.project.versions) + return (
-
- - - {props.project.logo ? ( - <> + + {props.project.logo ? + <> + {`${props.project.name} + + : <> + } + +
+ +
+ {props.project.name}{' '} + + {latestVersion.name} + +
+ -
- {props.project.name}{' '} - - { - ProjectRepository.getLatestVersion(props.project.versions) - .name - } - -
- - ) : ( -
- {props.project.name}{' '} - - { - ProjectRepository.getLatestVersion(props.project.versions) - .name - } - -
- )} - + +
+ {timeSince(new Date(latestVersion.timestamp))} ago +
+
+
+
+ {props.project.versions.length === 1 + ? `${props.project.versions.length} version` + : `${props.project.versions.length} versions`} +
+
-
- {props.project.versions.length === 1 - ? `${props.project.versions.length} version` - : `${props.project.versions.length} versions`} -
) } diff --git a/web/src/components/ProjectList.tsx b/web/src/components/ProjectList.tsx index 028ef616f..a6a7d8c37 100644 --- a/web/src/components/ProjectList.tsx +++ b/web/src/components/ProjectList.tsx @@ -1,8 +1,7 @@ import Project from './Project' -import React from 'react' -import styles from './../style/components/ProjectList.module.css' import { type Project as ProjectType } from '../models/ProjectsResponse' +import styles from './../style/components/ProjectList.module.css' interface Props { projects: ProjectType[] diff --git a/web/src/components/SearchBar.tsx b/web/src/components/SearchBar.tsx index 6987187f2..932934ded 100644 --- a/web/src/components/SearchBar.tsx +++ b/web/src/components/SearchBar.tsx @@ -1,31 +1,74 @@ -import _ from 'lodash' -import { TextField } from '@mui/material' -import React, { useCallback } from 'react' -import styles from '../style/components/SearchBar.module.css' -import { useSearch } from '../data-providers/SearchProvider' +import SearchIcon from '@mui/icons-material/Search'; +import StarIcon from '@mui/icons-material/Star'; +import StarBorderIcon from '@mui/icons-material/StarBorder'; +import { Divider, IconButton, InputBase, Paper, Tooltip } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useSearch } from '../data-providers/SearchProvider'; + + +interface Props { + showFavourites: boolean + onShowFavourites: (all: boolean) => void +} + +export default function SearchBar(props: Props): JSX.Element { + const [showFavourites, setShowFavourites] = useState(true); + const [searchParams, setSearchParams] = useSearchParams(); -export default function SearchBar(): JSX.Element { const { query, setQuery } = useSearch() const [searchQuery, setSearchQuery] = React.useState(query) - const updateSearchQueryInDataProvider = useCallback( - _.debounce((query: string): void => { - setQuery(query) - }, 500), - [] - ) + + const updateSearch = (q: string) => { + setSearchQuery(q) + setQuery(q) + + if (q) { + setSearchParams({q}) + } else { + setSearchParams({}) + } + } + + useEffect(() => { + const q = searchParams.get("q") + if (q) { + updateSearch(q) + } + setShowFavourites(props.showFavourites) + }, [props.showFavourites]); + + const onFavourites = (show: boolean): void => { + setSearchParams({}) + setSearchQuery("") + setQuery("") + + setShowFavourites(show) + props.onShowFavourites(!show) + } const onSearch = (e: React.ChangeEvent): void => { - setSearchQuery(e.target.value) - updateSearchQueryInDataProvider.cancel() - updateSearchQueryInDataProvider(e.target.value) + updateSearch(e.target.value) } return ( -
- + { @@ -34,8 +77,17 @@ export default function SearchBar(): JSX.Element { setQuery(searchQuery) } }} - variant="standard" - > -
+ + /> + + + + + + onFavourites(!showFavourites)} sx={{ p: '10px' }} aria-label="directions"> + { showFavourites ? : } + + + ) } diff --git a/web/src/components/StyledForm.tsx b/web/src/components/StyledForm.tsx index 8b12da998..5d1b87a12 100644 --- a/web/src/components/StyledForm.tsx +++ b/web/src/components/StyledForm.tsx @@ -1,5 +1,4 @@ import styles from './../style/components/StyledForm.module.css' -import React from 'react' interface Props { children: JSX.Element[] diff --git a/web/src/components/UploadButton.tsx b/web/src/components/UploadButton.tsx deleted file mode 100644 index 88e110120..000000000 --- a/web/src/components/UploadButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' -import { FileUpload } from '@mui/icons-material' -import { Link } from 'react-router-dom' -import { Tooltip } from '@mui/material' - -import styles from './../style/components/ControlButtons.module.css' - -interface Props { - isSingleButton?: boolean -} - -export default function UploadButton(props: Props): JSX.Element { - return ( - <> - - - - - - - ) -} diff --git a/web/src/index.css b/web/src/index.css index ebe842dc4..d20ed693f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,13 +1,12 @@ * { margin: 0; padding: 0; - font-family: "Avenir", Helvetica, Arial, sans-serif; + font-family: "Roboto", Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; --primary-foreground: #383838; --secondary-foreground: #e8e8e8; - --background: #ececec; --button-primary: #2c3e50; --icons: #505050; } diff --git a/web/src/models/ProjectDetails.ts b/web/src/models/ProjectDetails.ts index cbfb3a929..7221ddbf6 100644 --- a/web/src/models/ProjectDetails.ts +++ b/web/src/models/ProjectDetails.ts @@ -1,11 +1,13 @@ export default class ProjectDetails { name: string hidden: boolean + timestamp: Date tags: string[] - constructor(name: string, tags: string[], hidden: boolean) { + constructor(name: string, tags: string[], hidden: boolean, timestamp: Date) { this.name = name this.tags = tags this.hidden = hidden + this.timestamp = timestamp } } diff --git a/web/src/pages/Help.tsx b/web/src/pages/Help.tsx index 6be7ace88..06e8164b5 100644 --- a/web/src/pages/Help.tsx +++ b/web/src/pages/Help.tsx @@ -1,12 +1,11 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import ReactMarkdown from 'react-markdown' // @ts-expect-error ts can't read symbols from a md file import gettingStarted from './../assets/getting-started.md' -import UploadButton from '../components/UploadButton' -import Header from '../components/Header' import Footer from '../components/Footer' +import Header from '../components/Header' import LoadingPage from './LoadingPage' import styles from './../style/pages/Help.module.css' @@ -62,7 +61,6 @@ export default function Help(): JSX.Element { {content} -
diff --git a/web/src/components/SearchBar.tsx b/web/src/components/SearchBar.tsx index 932934ded..8c5c8f74f 100644 --- a/web/src/components/SearchBar.tsx +++ b/web/src/components/SearchBar.tsx @@ -59,10 +59,8 @@ export default function SearchBar(props: Props): JSX.Element { p: '2px 4px', display: 'flex', alignItems: 'center', - width: 600, - marginTop: '24px', + maxWidth: 600, marginLeft: '16px', - marginBottom: '32px' }} > - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + { projects.length === 0 ? From dc559809d4fe7a37415bb694cb1ff7cf81cf6c4e Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Wed, 16 Oct 2024 14:36:35 +0200 Subject: [PATCH 06/23] Redesign docs control button --- .../DocumentControlButtons.module.css | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/web/src/style/components/DocumentControlButtons.module.css b/web/src/style/components/DocumentControlButtons.module.css index 49e328ba2..df4685687 100644 --- a/web/src/style/components/DocumentControlButtons.module.css +++ b/web/src/style/components/DocumentControlButtons.module.css @@ -11,7 +11,7 @@ .share-button { height: 50px; width: 50px; - color: white; + color: rgba(0, 0, 0, 0.87); display: flex; align-items: center; justify-content: center; @@ -19,20 +19,20 @@ } .home-button { - background-color: var(--primary-foreground); + background-color: #efefef; border-top-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem; } .share-button { - background-color: #868686; - border-top-right-radius: 0.5rem; - border-bottom-right-radius: 0.5rem; + color: rgba(0, 0, 0, 0.87); + margin-left: 8px; + border-radius: 0.5rem; + cursor: pointer; } .version-select { background: white; - border: 1px solid rgba(0, 0, 0, 0.42); overflow: hidden; padding: 9px; @@ -40,9 +40,13 @@ font-size: 1.05em; - border-radius: 0 !important; + border-left-color: #efefef !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + } + .version-select:focus-visible { outline: none; } From 94fcfd983758448510403c041412befe75d9629d Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Thu, 17 Oct 2024 16:07:27 +0200 Subject: [PATCH 07/23] Add system stats and project size --- docat/docat/app.py | 10 +++++- docat/docat/models.py | 8 +++++ docat/docat/utils.py | 60 +++++++++++++++++++++++++++++++-- docat/tests/test_hide_show.py | 9 ++++- docat/tests/test_project.py | 24 ++++++++++--- docat/tests/test_stats.py | 33 ++++++++++++++++++ docat/tests/test_upload_icon.py | 2 ++ 7 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 docat/tests/test_stats.py diff --git a/docat/docat/app.py b/docat/docat/app.py index a88c78516..c6de77208 100644 --- a/docat/docat/app.py +++ b/docat/docat/app.py @@ -22,7 +22,7 @@ from starlette.responses import JSONResponse from tinydb import Query, TinyDB -from docat.models import ApiResponse, ClaimResponse, ProjectDetail, Projects, TokenStatus +from docat.models import ApiResponse, ClaimResponse, ProjectDetail, Projects, Stats, TokenStatus from docat.utils import ( DB_PATH, UPLOAD_FOLDER, @@ -31,6 +31,7 @@ extract_archive, get_all_projects, get_project_details, + get_system_stats, is_forbidden_project_name, remove_docs, ) @@ -65,6 +66,13 @@ def get_db() -> TinyDB: ) +@app.get("/api/stats", response_model=Stats, status_code=status.HTTP_200_OK) +def get_stats(): + if not DOCAT_UPLOAD_FOLDER.exists(): + return Projects(projects=[]) + return get_system_stats(DOCAT_UPLOAD_FOLDER) + + @app.get("/api/projects", response_model=Projects, status_code=status.HTTP_200_OK) def get_projects(include_hidden: bool = False): if not DOCAT_UPLOAD_FOLDER.exists(): diff --git a/docat/docat/models.py b/docat/docat/models.py index 5f15c3da4..76315b9fa 100644 --- a/docat/docat/models.py +++ b/docat/docat/models.py @@ -28,6 +28,7 @@ class ProjectVersion(BaseModel): class Project(BaseModel): name: str logo: bool + storage: str versions: list[ProjectVersion] @@ -35,6 +36,13 @@ class Projects(BaseModel): projects: list[Project] +class Stats(BaseModel): + n_projects: int + n_versions: int + storage: str + + class ProjectDetail(BaseModel): name: str + storage: str versions: list[ProjectVersion] diff --git a/docat/docat/utils.py b/docat/docat/utils.py index 22782ed14..ac0f71994 100644 --- a/docat/docat/utils.py +++ b/docat/docat/utils.py @@ -9,7 +9,7 @@ from pathlib import Path from zipfile import ZipFile, ZipInfo -from docat.models import Project, ProjectDetail, Projects, ProjectVersion +from docat.models import Project, ProjectDetail, Projects, ProjectVersion, Stats NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d") UPLOAD_FOLDER = "doc" @@ -125,6 +125,54 @@ def is_forbidden_project_name(name: str) -> bool: return name in ["upload", "claim", "delete", "help"] +UNITS_MAPPING = [ + (1 << 50, " PB"), + (1 << 40, " TB"), + (1 << 30, " GB"), + (1 << 20, " MB"), + (1 << 10, " KB"), + (1, " byte"), +] + + +def readable_size(bytes: int) -> str: + """ + Get human-readable file sizes. + simplified version of https://pypi.python.org/pypi/hurry.filesize/ + + https://stackoverflow.com/a/12912296/12356463 + """ + size_suffix = "" + for factor, suffix in UNITS_MAPPING: + if bytes >= factor: + size_suffix = suffix + break + + amount = int(bytes / factor) + if size_suffix == " byte" and amount > 1: + size_suffix = size_suffix + "s" + + if amount == 0: + size_suffix = " bytes" + + return str(amount) + size_suffix + + +def directory_size(path: Path) -> int: + return sum(file.stat().st_size for file in path.rglob("*") if file.is_file()) + + +def get_system_stats(upload_folder_path: Path) -> Stats: + """ + Return all docat statistics + """ + return Stats( + n_projects=len([p for p in upload_folder_path.iterdir() if p.is_dir()]), + n_versions=sum(len([p for p in d.iterdir() if p.is_dir() and not p.is_symlink()]) for d in upload_folder_path.glob("*/")), + storage=readable_size(directory_size(upload_folder_path)), + ) + + def get_all_projects(upload_folder_path: Path, include_hidden: bool) -> Projects: """ Returns all projects in the upload folder. @@ -145,7 +193,14 @@ def get_all_projects(upload_folder_path: Path, include_hidden: bool) -> Projects project_name = str(project.relative_to(upload_folder_path)) project_has_logo = (upload_folder_path / project / "logo").exists() - projects.append(Project(name=project_name, logo=project_has_logo, versions=details.versions)) + projects.append( + Project( + name=project_name, + logo=project_has_logo, + versions=details.versions, + storage=readable_size(directory_size(upload_folder_path / project)), + ) + ) return Projects(projects=projects) @@ -176,6 +231,7 @@ def should_include(name: str) -> bool: return ProjectDetail( name=project_name, + storage=readable_size(directory_size(docs_folder)), versions=sorted( [ ProjectVersion( diff --git a/docat/tests/test_hide_show.py b/docat/tests/test_hide_show.py index de3e836d3..28b605f6c 100644 --- a/docat/tests/test_hide_show.py +++ b/docat/tests/test_hide_show.py @@ -21,6 +21,7 @@ def test_hide(_, client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } @@ -34,6 +35,7 @@ def test_hide(_, client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [], } @@ -57,6 +59,7 @@ def test_hide_only_version_not_listed_in_projects(_, client_with_claimed_project { "name": "some-project", "logo": False, + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ], @@ -77,7 +80,7 @@ def test_hide_only_version_not_listed_in_projects(_, client_with_claimed_project # check versions hidden project_details_response = client_with_claimed_project.get("/api/projects/some-project") assert project_details_response.status_code == 200 - assert project_details_response.json() == {"name": "some-project", "versions": []} + assert project_details_response.json() == {"name": "some-project", "storage": "20 bytes", "versions": []} def test_hide_creates_hidden_file(client_with_claimed_project): @@ -216,6 +219,7 @@ def test_show(_, client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [], } @@ -229,6 +233,7 @@ def test_show(_, client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } @@ -389,6 +394,7 @@ def test_hide_and_show_with_tag(_, client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [], } @@ -402,5 +408,6 @@ def test_hide_and_show_with_tag(_, client_with_claimed_project): assert project_details_response.status_code == 200 assert project_details_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}], } diff --git a/docat/tests/test_project.py b/docat/tests/test_project.py index d75184508..cb8796063 100644 --- a/docat/tests/test_project.py +++ b/docat/tests/test_project.py @@ -26,6 +26,7 @@ def test_project_api(_, temp_project_version): { "name": "different-project", "logo": False, + "storage": "0 bytes", "versions": [ {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, ], @@ -33,6 +34,7 @@ def test_project_api(_, temp_project_version): { "name": "project", "logo": False, + "storage": "0 bytes", "versions": [ {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, ], @@ -61,6 +63,7 @@ def test_project_details_api(_, temp_project_version): assert response.status_code == httpx.codes.OK assert response.json() == { "name": "project", + "storage": "0 bytes", "versions": [{"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}], } @@ -86,7 +89,9 @@ def test_get_project_details_with_hidden_versions(_, client_with_claimed_project # check detected before hiding details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) assert details == ProjectDetail( - name="some-project", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)] + name="some-project", + storage="20 bytes", + versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)], ) # hide the version @@ -97,7 +102,9 @@ def test_get_project_details_with_hidden_versions(_, client_with_claimed_project # check hidden details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) assert details == ProjectDetail( - name="some-project", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=True)] + name="some-project", + storage="20 bytes", + versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=True)], ) @@ -115,7 +122,9 @@ def test_project_details_without_hidden_versions(_, client_with_claimed_project) # check detected before hiding details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False) assert details == ProjectDetail( - name="some-project", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)] + name="some-project", + storage="20 bytes", + versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)], ) # hide the version @@ -125,7 +134,7 @@ def test_project_details_without_hidden_versions(_, client_with_claimed_project) # check hidden details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False) - assert details == ProjectDetail(name="some-project", versions=[]) + assert details == ProjectDetail(name="some-project", storage="20 bytes", versions=[]) @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) @@ -147,6 +156,7 @@ def test_include_hidden_parameter_for_get_projects(_, client_with_claimed_projec { "name": "some-project", "logo": False, + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] @@ -160,6 +170,7 @@ def test_include_hidden_parameter_for_get_projects(_, client_with_claimed_projec { "name": "some-project", "logo": False, + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] @@ -183,6 +194,7 @@ def test_include_hidden_parameter_for_get_projects(_, client_with_claimed_projec { "name": "some-project", "logo": False, + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}], } ] @@ -205,6 +217,7 @@ def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } @@ -213,6 +226,7 @@ def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } @@ -226,6 +240,7 @@ def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [], } @@ -234,5 +249,6 @@ def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}], } diff --git a/docat/tests/test_stats.py b/docat/tests/test_stats.py new file mode 100644 index 000000000..166c4a9bc --- /dev/null +++ b/docat/tests/test_stats.py @@ -0,0 +1,33 @@ +import io +from datetime import datetime +from unittest.mock import patch + +import pytest + + +@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) +@pytest.mark.parametrize( + ("project_config", "n_projects", "n_versions", "storage"), + [ + ([("some-project", ["1.0.0"])], 1, 1, "20 bytes"), + ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), + ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), + ([("some-project", ["1.0.0", "2.0.0"]), ("another-project", ["1"])], 2, 3, "60 bytes"), + ], +) +def test_get_stats(_, project_config, n_projects, n_versions, storage, client_with_claimed_project): + """ + Make sure that get_stats works. + """ + # create a version + for project_name, versions in project_config: + for version in versions: + create_response = client_with_claimed_project.post( + f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} + ) + assert create_response.status_code == 201 + + # get system stats + hide_response = client_with_claimed_project.get("/api/stats") + assert hide_response.status_code == 200 + assert hide_response.json() == {"n_projects": n_projects, "n_versions": n_versions, "storage": storage} diff --git a/docat/tests/test_upload_icon.py b/docat/tests/test_upload_icon.py index 275b7afd7..1a22e447d 100644 --- a/docat/tests/test_upload_icon.py +++ b/docat/tests/test_upload_icon.py @@ -162,6 +162,7 @@ def test_get_project_recongizes_icon(_, client_with_claimed_project): { "name": "some-project", "logo": False, + "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] @@ -180,6 +181,7 @@ def test_get_project_recongizes_icon(_, client_with_claimed_project): { "name": "some-project", "logo": True, + "storage": "103 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] From 7551ce26200ec3051c42017f462aeff32ba91198 Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Thu, 17 Oct 2024 16:07:47 +0200 Subject: [PATCH 08/23] Display system stats and project size --- web/src/App.tsx | 16 ++-- web/src/components/Project.tsx | 3 +- web/src/data-providers/StatsDataProvider.tsx | 86 +++++++++++++++++++ web/src/models/ProjectsResponse.ts | 1 + web/src/pages/Home.tsx | 38 +++++++- web/src/style/pages/Home.module.css | 3 +- .../repositories/ProjectRepository.test.ts | 3 + 7 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 web/src/data-providers/StatsDataProvider.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index d71e2b54a..823667410 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,17 +1,17 @@ import { createHashRouter, RouterProvider } from 'react-router-dom' -import React from 'react' import { ConfigDataProvider } from './data-providers/ConfigDataProvider' +import { MessageBannerProvider } from './data-providers/MessageBannerProvider' import { ProjectDataProvider } from './data-providers/ProjectDataProvider' +import { SearchProvider } from './data-providers/SearchProvider' +import { StatsDataProvider } from './data-providers/StatsDataProvider' import Claim from './pages/Claim' import Delete from './pages/Delete' import Docs from './pages/Docs' +import EscapeSlashForDocsPath from './pages/EscapeSlashForDocsPath' import Help from './pages/Help' import Home from './pages/Home' import NotFound from './pages/NotFound' import Upload from './pages/Upload' -import EscapeSlashForDocsPath from './pages/EscapeSlashForDocsPath' -import { MessageBannerProvider } from './data-providers/MessageBannerProvider' -import { SearchProvider } from './data-providers/SearchProvider' function App(): JSX.Element { const router = createHashRouter([ @@ -79,9 +79,11 @@ function App(): JSX.Element { - - - + + + + + diff --git a/web/src/components/Project.tsx b/web/src/components/Project.tsx index 7f42a3731..b74af46c3 100644 --- a/web/src/components/Project.tsx +++ b/web/src/components/Project.tsx @@ -3,7 +3,7 @@ import { type Project as ProjectType } from '../models/ProjectsResponse' import ProjectRepository from '../repositories/ProjectRepository' import styles from './../style/components/Project.module.css' -import { Box, Tooltip } from '@mui/material' +import { Box, Tooltip, Typography } from '@mui/material' import FavoriteStar from './FavoriteStar' interface Props { @@ -81,6 +81,7 @@ export default function Project(props: Props): JSX.Element { {props.project.versions.length === 1 ? `${props.project.versions.length} version` : `${props.project.versions.length} versions`} + {props.project.storage}
void +} + +const Context = createContext({ + stats: null, + loadingFailed: false, + reload: (): void => { + console.warn('StatsProvider not initialized') + } +}) + +/** + * Provides the stats of the docat instance + * If reloading is required, call the reload function. + */ +export function StatsDataProvider({ children }: any): JSX.Element { + const { showMessage } = useMessageBanner() + + const loadData = (): void => { + void (async (): Promise => { + try { + const response = await fetch('/api/stats') + + if (!response.ok) { + throw new Error( + `Failed to load stats, status code: ${response.status}` + ) + } + + const data: Stats = await response.json() + setState({ + stats: data, + loadingFailed: false, + reload: loadData + }) + } catch (e) { + console.error(e) + + showMessage({ + content: 'Failed to load stats', + type: 'error', + showMs: 6000 + }) + + setState({ + stats: null, + loadingFailed: true, + reload: loadData + }) + } + })() + } + + const [state, setState] = useState({ + stats: null, + loadingFailed: false, + reload: loadData + }) + + useEffect(() => { + loadData() + }, []) + + return {children} +} + +export const useStats = (): StatsState => useContext(Context) diff --git a/web/src/models/ProjectsResponse.ts b/web/src/models/ProjectsResponse.ts index d654c11de..594da31a2 100644 --- a/web/src/models/ProjectsResponse.ts +++ b/web/src/models/ProjectsResponse.ts @@ -3,6 +3,7 @@ import type ProjectDetails from './ProjectDetails' export interface Project { name: string logo: boolean + storage: string versions: ProjectDetails[] } diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 594c76876..1086c6a31 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -14,11 +14,13 @@ import LoadingPage from './LoadingPage'; import { Box, Button, IconButton, Tooltip, Typography } from '@mui/material'; import SearchBar from '../components/SearchBar'; +import { useStats } from '../data-providers/StatsDataProvider'; import styles from './../style/pages/Home.module.css'; export default function Home(): JSX.Element { const { loadingFailed } = useProjects() + const { stats, loadingFailed: statsLoadingFailed } = useStats() const { filteredProjects: projects, query, setQuery } = useSearch() const [showAll, setShowAll] = useState(false); const [favoriteProjects, setFavoriteProjects] = useState([]) @@ -54,7 +56,7 @@ export default function Home(): JSX.Element { updateFavorites() }, [projects]) - if (loadingFailed) { + if (loadingFailed || statsLoadingFailed) { return (
@@ -67,7 +69,7 @@ export default function Home(): JSX.Element { ) } - if (projects == null) { + if (projects == null || stats == null) { return } @@ -76,6 +78,9 @@ export default function Home(): JSX.Element {
+ + + : <> - FAVOURITES + FAVOURITES { (favoriteProjects.length === 0) ? No docs favourited at the moment, search for docs or @@ -168,7 +173,34 @@ export default function Home(): JSX.Element { } } + + + INSTANCE STATS + + + # + DOCS + {stats.n_projects} + + # + VERSIONS + {stats.n_versions} + + + STORAGE + {stats.storage} +
diff --git a/web/src/style/pages/Home.module.css b/web/src/style/pages/Home.module.css index 86f889a9a..2f219170c 100644 --- a/web/src/style/pages/Home.module.css +++ b/web/src/style/pages/Home.module.css @@ -19,7 +19,8 @@ .project-overview { display: flex; - flex-direction: column; + flex-direction: row; + align-items: flex-start; } .card { diff --git a/web/src/tests/repositories/ProjectRepository.test.ts b/web/src/tests/repositories/ProjectRepository.test.ts index 1c58a96e2..41a5f909e 100644 --- a/web/src/tests/repositories/ProjectRepository.test.ts +++ b/web/src/tests/repositories/ProjectRepository.test.ts @@ -352,6 +352,7 @@ describe('filterHiddenVersions', () => { const allProjects: Project[] = [ { name: 'test-project-1', + storage: "1 MB", versions: [shownVersion, hiddenVersion], logo: false } @@ -360,6 +361,7 @@ describe('filterHiddenVersions', () => { const shownProjects: Project[] = [ { name: 'test-project-1', + storage: "1 MB", versions: [shownVersion], logo: false } @@ -372,6 +374,7 @@ describe('filterHiddenVersions', () => { const allProjects: Project[] = [ { name: 'test-project-1', + storage: "1 MB", versions: [ { name: 'v-1', From a3a1e92c4088321f1da9d17af12efdae189d4e0b Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Thu, 17 Oct 2024 16:13:23 +0200 Subject: [PATCH 09/23] Improve README spelling Co-authored-by: Timo Furrer --- README.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 78f0031bd..1a5d1c75f 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ ## Why DoCat? -When generating static developer documentation using +When generating static documentation using [mkdocs](https://www.mkdocs.org/), [sphinx](http://www.sphinx-doc.org/en/master/), ... hosting just one version of the docs might not be enough. Many users might still use older versions and might need to read those versions of the documentation. -Docat solves this problem by providing a simple tool that can -host multiple documentation projects with multiple versions. +Docat solves this problem by providing a simple tool that +hosts multiple documentation projects with multiple versions. -*The main design decision with docat was to keep the tool as simpel as possible* +*The main design decision with docat was to keep the tool as simple as possible.* ## Getting started @@ -40,19 +40,21 @@ Go to [localhost:8000](http://localhost:8000) to view your docat instance: ### Using DoCat -> 🛈 Please note that docat does not provide any way to write documentation -> the tool only hosts documentation. +> 🛈 Please note that docat does not provide any way to write documentation. +> It's sole responsibility is to host documentation. > -> There are many awesome tools to write developer documenation: -> [mkdocs](https://www.mkdocs.org/), [sphinx](http://www.sphinx-doc.org/en/master/), -> [mdbook](https://rust-lang.github.io/mdBook/) ... +> There are many awesome tools to write documenation: +> - [mkdocs](https://www.mkdocs.org/) +> - [sphinx](http://www.sphinx-doc.org/en/master/) +> - [mdbook](https://rust-lang.github.io/mdBook/) +> - ... -A small tool called [docatl](https://github.com/docat-org/docatl) is provided +A CLI tool called [docatl](https://github.com/docat-org/docatl) is available for easy interaction with the docat server. However, interacting with docat can also be done through [`curl`](doc/getting-started.md). -So in order to push documentation (and tag as `latest`) in the folder `docs/` simply run: +To push documentation (and tag as `latest`) in the folder `docs/` simply run: ```sh docatl push --host http://localhost:8000 ./docs PROJECT VERSION --tag latest From 8c52b4216efe39c70be94712b7c4f52ceff2a71b Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Thu, 17 Oct 2024 16:22:34 +0200 Subject: [PATCH 10/23] Add type check to makefile --- docat/Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docat/Makefile b/docat/Makefile index 2b366d561..90e02ae64 100644 --- a/docat/Makefile +++ b/docat/Makefile @@ -1,7 +1,12 @@ +.PHONY: all +all: format lint typing pytest + format: poetry run ruff check --fix poetry run ruff format lint: poetry run ruff check +typing: + poetry run mypy . pytest: poetry run pytest From ec1999c463b833dd5121bb86209f6ac16fb14a9a Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Thu, 17 Oct 2024 23:07:50 +0200 Subject: [PATCH 11/23] Cache folder size --- docat/docat/app.py | 12 ++++++++++++ docat/docat/utils.py | 18 ++++++++++++++++-- docat/tests/test_stats.py | 8 ++++---- docat/tests/test_upload_icon.py | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/docat/docat/app.py b/docat/docat/app.py index c6de77208..bc4a84b57 100644 --- a/docat/docat/app.py +++ b/docat/docat/app.py @@ -28,6 +28,7 @@ UPLOAD_FOLDER, calculate_token, create_symlink, + directory_size, extract_archive, get_all_projects, get_project_details, @@ -139,6 +140,10 @@ def upload_icon( with icon_path.open("wb") as buffer: shutil.copyfileobj(file.file, buffer) + # recalculate size cache + directory_size(project_base_path, recalculate=True) + directory_size(DOCAT_UPLOAD_FOLDER, recalculate=True) + return ApiResponse(message="Icon successfully uploaded") @@ -266,6 +271,10 @@ def upload( if not (base_path / "index.html").exists(): return ApiResponse(message="Documentation uploaded successfully, but no index.html found at root of archive.") + # recalculate size cache + directory_size(project_base_path, recalculate=True) + directory_size(DOCAT_UPLOAD_FOLDER, recalculate=True) + return ApiResponse(message="Documentation uploaded successfully") @@ -372,6 +381,9 @@ def delete( response.status_code = status.HTTP_404_NOT_FOUND return ApiResponse(message=message) + # recalculate size cache + directory_size(DOCAT_UPLOAD_FOLDER, recalculate=True) + return ApiResponse(message=f"Successfully deleted version '{version}'") diff --git a/docat/docat/utils.py b/docat/docat/utils.py index ac0f71994..6a4f45c10 100644 --- a/docat/docat/utils.py +++ b/docat/docat/utils.py @@ -93,6 +93,9 @@ def remove_docs(project: str, version: str, upload_folder_path: Path): if not link.resolve().exists(): link.unlink() + # remove size info + (upload_folder_path / project / ".size").unlink(missing_ok=True) + # remove empty projects if not [d for d in docs.parent.iterdir() if d.is_dir()]: docs.parent.rmdir() @@ -158,8 +161,19 @@ def readable_size(bytes: int) -> str: return str(amount) + size_suffix -def directory_size(path: Path) -> int: - return sum(file.stat().st_size for file in path.rglob("*") if file.is_file()) +def directory_size(path: Path, recalculate: bool = False) -> int: + """ + Returns the size of a directory and caches it's size unless + recalculate is set to true or the cache is missed + """ + size_info = path / ".size" + if size_info.exists() and not recalculate: + return int(size_info.read_text()) + + dir_size = sum(file.stat().st_size for file in path.rglob("*") if file.is_file()) + size_info.write_text(str(dir_size)) # cache directory size + + return dir_size def get_system_stats(upload_folder_path: Path) -> Stats: diff --git a/docat/tests/test_stats.py b/docat/tests/test_stats.py index 166c4a9bc..f1bfe7ba0 100644 --- a/docat/tests/test_stats.py +++ b/docat/tests/test_stats.py @@ -9,10 +9,10 @@ @pytest.mark.parametrize( ("project_config", "n_projects", "n_versions", "storage"), [ - ([("some-project", ["1.0.0"])], 1, 1, "20 bytes"), - ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), - ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), - ([("some-project", ["1.0.0", "2.0.0"]), ("another-project", ["1"])], 2, 3, "60 bytes"), + ([("some-project", ["1.0.0"])], 1, 1, "22 bytes"), + ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "44 bytes"), + ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "44 bytes"), + ([("some-project", ["1.0.0", "2.0.0"]), ("another-project", ["1"])], 2, 3, "66 bytes"), ], ) def test_get_stats(_, project_config, n_projects, n_versions, storage, client_with_claimed_project): diff --git a/docat/tests/test_upload_icon.py b/docat/tests/test_upload_icon.py index 1a22e447d..03a046551 100644 --- a/docat/tests/test_upload_icon.py +++ b/docat/tests/test_upload_icon.py @@ -181,7 +181,7 @@ def test_get_project_recongizes_icon(_, client_with_claimed_project): { "name": "some-project", "logo": True, - "storage": "103 bytes", + "storage": "105 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] From 086392c534f2818fe3f4c36b95ea75e6981fd075 Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Thu, 17 Oct 2024 23:23:02 +0200 Subject: [PATCH 12/23] Fix control button --- web/src/pages/Home.tsx | 7 ++++--- web/src/style/components/ProjectList.module.css | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 1086c6a31..0f913598f 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -78,7 +78,7 @@ export default function Home(): JSX.Element {
- + @@ -176,7 +176,8 @@ export default function Home(): JSX.Element { Date: Fri, 18 Oct 2024 09:55:29 +0200 Subject: [PATCH 13/23] Fix upload/claim/delete button links --- web/src/pages/Home.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 0f913598f..5bc68993b 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -105,7 +105,7 @@ export default function Home(): JSX.Element { @@ -114,7 +114,7 @@ export default function Home(): JSX.Element { @@ -123,7 +123,7 @@ export default function Home(): JSX.Element { From 19680986f2a8d43f2e25d8b041f4f95bd8fec2aa Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Fri, 18 Oct 2024 09:55:52 +0200 Subject: [PATCH 14/23] Add 'show all docs' button --- web/src/pages/Home.tsx | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 5bc68993b..0fd9bd454 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { Delete, ErrorOutline, FileUpload, Lock } from '@mui/icons-material'; +import { Delete, ErrorOutline, FileUpload, KeyboardArrowDown, Lock } from '@mui/icons-material'; import { useLocation } from 'react-router'; import { useProjects } from '../data-providers/ProjectDataProvider'; import { useSearch } from '../data-providers/SearchProvider'; @@ -13,6 +13,7 @@ import ProjectRepository from '../repositories/ProjectRepository'; import LoadingPage from './LoadingPage'; import { Box, Button, IconButton, Tooltip, Typography } from '@mui/material'; +import { Link } from 'react-router-dom'; import SearchBar from '../components/SearchBar'; import { useStats } from '../data-providers/StatsDataProvider'; import styles from './../style/pages/Home.module.css'; @@ -162,12 +163,23 @@ export default function Home(): JSX.Element { : - { - updateFavorites() - }} - /> + <> + { + updateFavorites() + }} + /> + + + onShowFavourites(true)} > + SHOW ALL DOCS + + + + } } From 2f6f28363a19bd948ed0a8bad3f39895e02b3ff5 Mon Sep 17 00:00:00 2001 From: Benj Fassbind Date: Fri, 18 Oct 2024 10:03:20 +0200 Subject: [PATCH 15/23] Improve docs control hover effects --- web/src/components/DocumentControlButtons.tsx | 16 +++++++++++++--- .../components/DocumentControlButtons.module.css | 5 +++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/web/src/components/DocumentControlButtons.tsx b/web/src/components/DocumentControlButtons.tsx index a7501866f..214e363f1 100644 --- a/web/src/components/DocumentControlButtons.tsx +++ b/web/src/components/DocumentControlButtons.tsx @@ -1,5 +1,3 @@ -import React, { useState } from 'react' -import { Link } from 'react-router-dom' import { Home, Share } from '@mui/icons-material' import { Checkbox, @@ -11,6 +9,8 @@ import { Select, Tooltip } from '@mui/material' +import { useState } from 'react' +import { Link } from 'react-router-dom' import type ProjectDetails from '../models/ProjectDetails' import styles from './../style/components/DocumentControlButtons.module.css' @@ -50,7 +50,7 @@ export default function DocumentControlButtons(props: Props): JSX.Element { return (
- + @@ -58,6 +58,16 @@ export default function DocumentControlButtons(props: Props): JSX.Element {