11 Commits
17 changed files with 2992 additions and 792 deletions
+6
View File
@@ -32,3 +32,9 @@ node_modules/
# Backup files # Backup files
*.bak *.bak
*.backup *.backup
# runtime-generated files
access_log.json
# User and passwords
config/users.json
+79 -8
View File
@@ -14,8 +14,12 @@ Backend:
- `app.py` creates the Flask app. - `app.py` creates the Flask app.
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`. - HTTP Basic Auth is implemented with `Flask-HTTPAuth`.
- Users are currently configured in `BETA_USERS` with Werkzeug password hashes. - Users are configured in local deployment file `config/users.json` with Werkzeug password hashes. This file is ignored by Git.
- `/` renders the active calculator template. - `/` renders the active calculator template.
- `/help` renders the authenticated Help landing page.
- `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout.
- `/help/recent-changes` renders `docs/de/recent_changes.md` server-side inside the RollCalc Help layout.
- `/help/user-manual/screenshots/<path:filename>` serves German manual screenshots from `docs/de/screenshots/`.
- `/static/<path:filename>` is intended to serve static files behind Basic Auth. - `/static/<path:filename>` is intended to serve static files behind Basic Auth.
- `/api/health` returns health/version information. - `/api/health` returns health/version information.
- `/api/user` returns the authenticated user. - `/api/user` returns the authenticated user.
@@ -50,23 +54,90 @@ If `build_info.json` is missing, malformed, or does not contain a usable value,
The data is loaded centrally in `app.py` and made available to every template as `build_info`. The data is loaded centrally in `app.py` and made available to every template as `build_info`.
## Help and User Manual
The in-app Help area keeps `docs/de/user_manual.md` as the leading German documentation source. RollCalc reads the Markdown file on each `/help/user-manual` request and renders it server-side with `markdown-it-py`.
The documentation structure is prepared for language-specific manuals:
```text
docs/
de/
user_manual.md
screenshots/
```
Additional languages can follow the same structure later. The current public route still opens the German manual and no language switcher is implemented.
Rendering rules:
- Raw HTML from the Markdown source is disabled.
- Heading ids and the table of contents are generated from the Markdown content.
- Relative image references such as `screenshots/disclaimer.png` are rewritten to the authenticated manual screenshot route.
- Screenshots belong in the active language's `screenshots/` directory, currently `docs/de/screenshots/`; the screenshot route must remain limited to that directory.
The Help pages use the same HTTP Basic Auth protection as the calculator.
### Recent Changes and What's New
Full user-facing release notes are maintained in:
```text
docs/de/recent_changes.md
```
The calculator header links directly to `/help/user-manual`. The Help landing page lists only existing documents, currently User Manual and Recent Changes.
The startup What's New dialog uses `build_info.version` and is suppressed when the version is empty or `unknown`. The last acknowledged version is stored only in browser `localStorage` under:
```text
rollcalc_last_seen_version
```
The short dialog item list is maintained separately in:
```text
static/recent_changes.json
```
This JSON is a compact summary for the dialog, not the complete release history. Keep it aligned with the most relevant current entries from `docs/de/recent_changes.md`.
## Authentication ## Authentication
RollCalc uses HTTP Basic Auth. User entries in `BETA_USERS` have this shape: RollCalc uses HTTP Basic Auth. User entries are stored in the local deployment file:
```python ```text
"username": { config/users.json
"password_hash": "..." ```
This file is intentionally ignored by Git. The versioned file `config/users.example.json` documents the JSON format only and must not contain real usernames, hashes, or passwords.
The file stores username-to-password-hash mappings:
```json
{
"username": "<hash>"
} }
``` ```
Password verification uses `werkzeug.security.check_password_hash`. New hashes or config snippets can be generated with: Password verification uses `werkzeug.security.check_password_hash`. Users are managed with:
```bash ```bash
python scripts/manage_users.py username python3 scripts/manage_users.py
``` ```
Passwords and hashes must not be logged. The management tool provides a text menu for listing users, creating users, changing passwords, and deleting users. New hashes use the existing RollCalc method `pbkdf2:sha256:600000`.
The Flask app requires a readable, valid, non-empty `config/users.json` at startup and does not silently create an empty user file. If the file is missing or invalid, startup should fail with a message pointing to `python3 scripts/manage_users.py`.
Passwords must not be logged. Password hashes should only be stored in `config/users.json` or a future protected deployment-specific secret source.
Deployment notes:
- The systemd/service user must be able to read `config/users.json`.
- Suitable server permissions can be owner `martin`, group `www-data`, mode `640`, adjusted to the actual deployment users.
- Do not hard-code deployment owners or groups in Python.
- Back up the productive `config/users.json` before deploying or replacing a server instance.
## Roll Geometry ## Roll Geometry
+126 -11
View File
@@ -9,6 +9,7 @@ This README is intended for developers maintaining the project, not for end user
- Python 3 - Python 3
- Flask 2.3.3 - Flask 2.3.3
- Flask-HTTPAuth 4.8.0 - Flask-HTTPAuth 4.8.0
- markdown-it-py 3.0.0 for server-side Markdown rendering
- Vanilla HTML, CSS, and JavaScript - Vanilla HTML, CSS, and JavaScript
- JSON files for product data and forklift/load rules - JSON files for product data and forklift/load rules
@@ -42,7 +43,7 @@ If port `5000` is already occupied, start through Flask's CLI without changing f
flask --app app run --host 127.0.0.1 --port 5001 flask --app app run --host 127.0.0.1 --port 5001
``` ```
The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.py` with Werkzeug password hashes. The app uses HTTP Basic Auth. Current users are defined in a local deployment file, `config/users.json`, with Werkzeug password hashes. This file is intentionally not versioned.
## Project Layout ## Project Layout
@@ -58,7 +59,9 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
├── fix_article_data.py ├── fix_article_data.py
├── service-worker.js ├── service-worker.js
├── templates/ ├── templates/
│ └── roll_calculator.html │ ├── help_index.html
│ ├── roll_calculator.html
│ └── user_manual.html
├── static/ ├── static/
│ ├── article-data.json │ ├── article-data.json
│ ├── config.json │ ├── config.json
@@ -71,6 +74,9 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
│ ├── rollcalc_stddev_ranges.js │ ├── rollcalc_stddev_ranges.js
│ └── rollcalc_stddev_integration.js │ └── rollcalc_stddev_integration.js
└── docs/ └── docs/
├── de/
│ ├── screenshots/
│ └── user_manual.md
├── DEPLOYMENT_GUIDE.md ├── DEPLOYMENT_GUIDE.md
├── QOL_UPDATE_SUMMARY.md ├── QOL_UPDATE_SUMMARY.md
├── STDDEV_IMPLEMENTATION_CHECKLIST.md ├── STDDEV_IMPLEMENTATION_CHECKLIST.md
@@ -86,29 +92,138 @@ Implemented routes:
| Route | Methods | Auth | Purpose | | Route | Methods | Auth | Purpose |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. | | `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. |
| `/help` | `GET` | Basic Auth | Renders the Help landing page. |
| `/help/user-manual` | `GET` | Basic Auth | Reads `docs/de/user_manual.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/recent-changes` | `GET` | Basic Auth | Reads `docs/de/recent_changes.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/user-manual/screenshots/<path:filename>` | `GET` | Basic Auth | Serves German user-manual screenshots from `docs/de/screenshots/` only. |
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. | | `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
| `/api/health` | `GET` | Basic Auth | Returns app health and version. | | `/api/health` | `GET` | Basic Auth | Returns app health and version. |
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. | | `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
Authentication is implemented with `Flask-HTTPAuth`. The current code checks `BETA_USERS` with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application. Authentication is implemented with `Flask-HTTPAuth`. The current code loads the local `config/users.json` deployment file and checks password hashes with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application.
Generate a password hash or user entry with: ## User Management
Manage users with the interactive CLI:
```bash ```bash
python scripts/manage_users.py username python3 scripts/manage_users.py
python scripts/manage_users.py username --json
``` ```
For non-interactive local maintenance only: The menu supports:
```bash - List users
python scripts/manage_users.py username --password 'new-password' - Create user
- Change password
- Delete user
User data is stored in:
```text
config/users.json
```
`config/users.json` is local deployment data and is ignored by Git. The versioned file `config/users.example.json` documents the format only and must not contain real usernames, hashes, or passwords.
The file contains only username-to-password-hash mappings:
```json
{
"username": "<hash>"
}
```
Passwords are entered with `getpass`, are never echoed, and are never stored in plaintext. New and changed passwords use the existing RollCalc hash method:
```text
pbkdf2:sha256:600000
``` ```
Do not commit real passwords or print them in logs. Do not commit real passwords or print them in logs.
On a server, ensure that the process running RollCalc can read `config/users.json`. A suitable deployment setup can be:
```text
owner: martin
group: www-data
mode: 640
```
The exact owner and group depend on the deployment. Do not hard-code them in the application. Before deploying or replacing a server instance, back up the productive `config/users.json` file.
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back. Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
## Help and User Manual
RollCalc exposes an authenticated Help area at:
```text
/help
```
The user manual is available at:
```text
/help/user-manual
```
Recent changes are available at:
```text
/help/recent-changes
```
`docs/de/user_manual.md` is the leading source for the German user manual. The app reads this Markdown file on request and renders it server-side with `markdown-it-py`; no generated static HTML copy is maintained. Raw HTML in the Markdown source is disabled during rendering.
`docs/de/recent_changes.md` is the leading source for full German release notes. It is rendered through the same authenticated Markdown infrastructure.
The documentation directory is prepared for additional languages:
```text
docs/
de/
user_manual.md
screenshots/
```
The public route `/help/user-manual` currently opens the German manual. No language switcher is implemented yet.
Screenshots referenced from the manual as `screenshots/filename.png` should be placed in:
```text
docs/de/screenshots/
```
The application rewrites those relative Markdown image paths to the authenticated manual screenshot route, which is limited to the active language's screenshot directory.
### What's New Dialog
The calculator page shows a compact What's New dialog once per build version. The current version comes from `build_info.version`; if that value is empty or `unknown`, the dialog is not shown.
The last acknowledged version is stored only in the browser:
```text
rollcalc_last_seen_version
```
The short dialog summary is maintained in:
```text
static/recent_changes.json
```
Keep `docs/de/recent_changes.md` as the complete release-note source and `static/recent_changes.json` as the short 3-5 item summary for the startup dialog.
## User Manual PDF Export
Generate the current PDF version of the German user manual with:
```bash
./scripts/build_user_manual_pdf.sh
```
The script renders `docs/de/user_manual.md` to `docs/de/user_manual.pdf` with Pandoc. The Markdown file remains the leading source.
## Frontend Entry Point ## Frontend Entry Point
The active UI is `templates/roll_calculator.html`. The active UI is `templates/roll_calculator.html`.
@@ -302,11 +417,11 @@ Utility script that updates relative frontend fetch/register paths to Flask-styl
- There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template. - There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template.
- The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly. - The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly.
- The Flask dev server is used for local development only. Production should use a WSGI server. - The Flask dev server is used for local development only. Production should use a WSGI server.
- Use `scripts/manage_users.py` to create password hashes when adding or rotating Basic Auth users. - Use `scripts/manage_users.py` to add users, rotate passwords, or delete users.
## Known Risks and Maintenance Items ## Known Risks and Maintenance Items
- Password hashes are currently configured in `app.py`; user configuration should eventually move to environment variables, a protected config file, or a secrets manager. - Password hashes are currently stored in `config/users.json`; a secrets manager or protected deployment-specific config would be more robust for production.
- `access_log.json` is not safe for concurrent writes. - `access_log.json` is not safe for concurrent writes.
- `access_log.json` grows without rotation or retention limits. - `access_log.json` grows without rotation or retention limits.
- The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection. - The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection.
-667
View File
@@ -1,667 +0,0 @@
[
{
"timestamp": "2026-05-28T13:06:19.854940",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:06:58.223722",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:05.902636",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:51.345366",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:09:37.481260",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:14:02.867188",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:28:08.386403",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:29:06.541184",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:30:47.979071",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:05.447815",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:19.317688",
"username": "beta",
"endpoint": "/admin",
"method": "GET",
"status": 403
},
{
"timestamp": "2026-05-28T14:42:29.477028",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.374462",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.411860",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.562482",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.420524",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.438789",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.773466",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.817782",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.894128",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-06-03T05:20:48.808775",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.451519",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.676701",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:54.481445",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:58.011356",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:32:48.747837",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:33:07.753648",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:42:11.820817",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:43:37.598978",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:47:39.689201",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T08:25:47.585793",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:32:59.419063",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:35:05.236655",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:40:47.890997",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:41:02.461403",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:11.914989",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:57.125161",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:54:34.067541",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T10:02:39.927663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:00:50.808697",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:28.615255",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:40.011779",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:53.681061",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:10:06.334747",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:17:18.862106",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:00.451943",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:20.277301",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:49:56.333142",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:50:11.692907",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:56:07.569410",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:03.417506",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:21.102453",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:04:52.131022",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:05:09.915771",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:09:55.899370",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.387853",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.736005",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:12:48.924277",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:15:13.236624",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:17:32.470202",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:23.613251",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:24.369105",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:26:48.783516",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:47:07.888072",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:52:38.653855",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:53:02.662397",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:57:19.316896",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:05:28.671663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:26.413418",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:42.715163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:15.337499",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:19.510992",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:13:17.018760",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:30.320494",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:46.119436",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:36:55.439798",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:41:31.868950",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:07:56.334163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:08:38.097844",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:39:44.284544",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:41:57.673023",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:42:19.877155",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T10:34:02.710459",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:14:58.113794",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:20.071507",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:32.538576",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T11:44:53.524315",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T11:47:36.702455",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T12:18:47.866971",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T14:46:22.335316",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:13:42.026433",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:19:41.488752",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:34:58.280178",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:36:56.896657",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:41:46.568545",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
}
]
+304 -32
View File
@@ -3,13 +3,27 @@ Naue Roll Calculator - Beta
Flask app with HTTP Basic Authentication Flask app with HTTP Basic Authentication
""" """
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify from flask import (
Flask,
abort,
jsonify,
render_template,
request,
send_file,
send_from_directory,
url_for,
)
from flask_httpauth import HTTPBasicAuth from flask_httpauth import HTTPBasicAuth
from markdown_it import MarkdownIt
from markupsafe import Markup
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from functools import wraps from functools import wraps
import os import os
from datetime import datetime from datetime import datetime
import json import json
import re
import socket
import unicodedata
app = Flask(__name__) app = Flask(__name__)
auth = HTTPBasicAuth() auth = HTTPBasicAuth()
@@ -18,31 +32,14 @@ auth = HTTPBasicAuth()
# CONFIGURATION # CONFIGURATION
# ============================================================================ # ============================================================================
# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!)
BETA_USERS = {
"beta": {
"password_hash": "pbkdf2:sha256:600000$HNtF3VdZKdmtg8Vw$8f976a99a457c5e924916dc6f735dd631042c8c126af8d4bda9abcd7532d904f"
},
"naue": {
"password_hash": "pbkdf2:sha256:600000$RVPiW2mYXJJIp3d7$0f838b8619d386e2da57e60ae8ccb944bb0f9f63d81ce3d174ec3034b0abcd19"
},
"cniehues": {
"password_hash": "pbkdf2:sha256:600000$iNmhakf34xk26xrz$ae78846461370c52b7f56fad5ac0ae8e33860d6f00075ac78e3a5b8c31d51a3d"
},
"lvollmert": {
"password_hash": "pbkdf2:sha256:600000$8FGop5ymmHpuIqfK$8ba223e20c514cf6bc1297435a2e7e2be81668951297f0e01ad6a931718ad7de"
},
"mtazl": {
"password_hash": "pbkdf2:sha256:600000$H19skoalhWxlLnY5$d30bfeae38470b19900648fd110978b3677607c3a161d663a7e5d5c2167a3710"
},
"controlling": {
"password_hash": "pbkdf2:sha256:600000$ksj86lrKz6nnSpXz$5161e0b5c76bd72d6b2cd04866ef49e69f463f474d7e47075171894bf3366f29"
}
}
# Logging für Auditing
LOG_FILE = "access_log.json" LOG_FILE = "access_log.json"
BUILD_INFO_FILE = "build_info.json" BUILD_INFO_FILE = "build_info.json"
USER_CONFIG_FILE = os.path.join(app.root_path, "config", "users.json")
DOCS_DIR = os.path.join(app.root_path, "docs")
DEFAULT_DOC_LANGUAGE = "de"
USER_MANUAL_FILENAME = "user_manual.md"
RECENT_CHANGES_FILENAME = "recent_changes.md"
WHATS_NEW_SUMMARY_FILE = os.path.join(app.root_path, "static", "recent_changes.json")
UNKNOWN_BUILD_INFO = { UNKNOWN_BUILD_INFO = {
"version": "unknown", "version": "unknown",
"branch": "unknown", "branch": "unknown",
@@ -75,23 +72,102 @@ def load_build_info():
BUILD_INFO = load_build_info() BUILD_INFO = load_build_info()
def load_whats_new_summary():
"""Load compact release notes for the What's New dialog."""
try:
with open(WHATS_NEW_SUMMARY_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"[What's New Error] {e}")
return []
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
return []
return [
item.strip()
for item in items
if isinstance(item, str) and item.strip()
][:5]
WHATS_NEW_SUMMARY = load_whats_new_summary()
@app.context_processor @app.context_processor
def inject_build_info(): def inject_build_info():
"""Make build metadata available in all templates.""" """Make build metadata and release-note summary available in templates."""
return {"build_info": BUILD_INFO} return {
"build_info": BUILD_INFO,
"whats_new_summary": WHATS_NEW_SUMMARY
}
# ============================================================================ # ============================================================================
# AUTHENTICATION # AUTHENTICATION
# ============================================================================ # ============================================================================
class UserConfigError(RuntimeError):
"""Raised when the user configuration is missing or invalid."""
def load_user_hashes(path=USER_CONFIG_FILE):
"""Load user password hashes from config/users.json."""
command_hint = "Run python3 scripts/manage_users.py to initialize or repair it."
if not os.path.exists(path):
raise UserConfigError(
f"User config file is missing: {path}. {command_hint}"
)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
raise UserConfigError(
f"Invalid JSON in user config file {path}: {e}. {command_hint}"
) from e
except Exception as e:
raise UserConfigError(
f"Could not read user config file {path}: {e}. {command_hint}"
) from e
if not isinstance(data, dict):
raise UserConfigError(
f"Invalid user config file {path}: root element must be an object. "
f"{command_hint}"
)
if not data:
raise UserConfigError(
f"User config file {path} does not contain any users. {command_hint}"
)
users = {}
for username, value in data.items():
if not isinstance(username, str) or not username.strip():
raise UserConfigError(
f"Invalid user config file {path}: usernames must be strings. "
f"{command_hint}"
)
if not isinstance(value, str) or not value.strip():
raise UserConfigError(
f"Invalid user config file {path}: password hash for "
f"user {username!r} must be a string. {command_hint}"
)
users[username.strip()] = value.strip()
return users
USER_PASSWORD_HASHES = load_user_hashes()
@auth.verify_password @auth.verify_password
def verify_password(username, password): def verify_password(username, password):
"""Verify HTTP Basic Auth credentials""" """Verify HTTP Basic Auth credentials"""
user_config = BETA_USERS.get(username) password_hash = USER_PASSWORD_HASHES.get(username)
if not user_config: if not password_hash:
return None return None
password_hash = user_config.get("password_hash")
if password_hash and check_password_hash(password_hash, password): if password_hash and check_password_hash(password_hash, password):
return username return username
return None return None
@@ -116,10 +192,119 @@ def log_access(username, endpoint, method, status=200):
except Exception as e: except Exception as e:
print(f"[Logging Error] {e}") print(f"[Logging Error] {e}")
def find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0"):
"""Find the first available TCP port at or above start_port."""
for port in range(start_port, start_port + max_attempts):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((host, port))
except OSError:
continue
return port
raise RuntimeError(
f"No free port found from {start_port} to "
f"{start_port + max_attempts - 1}"
)
# ============================================================================ # ============================================================================
# ROUTES # ROUTES
# ============================================================================ # ============================================================================
def slugify_heading(text):
"""Create stable, URL-friendly heading ids for rendered Markdown."""
normalized = unicodedata.normalize("NFKD", text)
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_text).strip("-").lower()
return slug or "section"
def build_manual_renderer():
"""Build a Markdown renderer with raw HTML disabled."""
renderer = MarkdownIt("commonmark", {"html": False})
renderer.enable("table")
return renderer
def get_document_path(filename, language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific help document."""
return os.path.join(DOCS_DIR, language, filename)
def get_manual_path(language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific manual."""
return get_document_path(USER_MANUAL_FILENAME, language)
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
"""Return the screenshot directory for a language-specific manual."""
return os.path.join(DOCS_DIR, language, "screenshots")
def rewrite_manual_asset_paths(markdown_text, language=DEFAULT_DOC_LANGUAGE):
"""Route relative manual screenshot links through the protected docs route."""
screenshot_url = url_for("manual_screenshot", filename="")
return markdown_text.replace("](screenshots/", f"]({screenshot_url}")
def add_heading_ids_and_toc(tokens):
"""Attach ids to headings and create a compact table of contents."""
toc = []
used_slugs = {}
for index, token in enumerate(tokens):
if token.type != "heading_open":
continue
level = int(token.tag[1])
inline_token = tokens[index + 1] if index + 1 < len(tokens) else None
title = (
inline_token.content
if inline_token and inline_token.type == "inline"
else ""
)
base_slug = slugify_heading(title)
count = used_slugs.get(base_slug, 0)
used_slugs[base_slug] = count + 1
slug = base_slug if count == 0 else f"{base_slug}-{count + 1}"
token.attrSet("id", slug)
if level <= 2:
toc.append({
"level": level,
"title": title,
"id": slug
})
return toc
def render_markdown_document(filename, language=DEFAULT_DOC_LANGUAGE):
"""Read and render a Markdown help document into HTML and a generated TOC."""
renderer = build_manual_renderer()
document_path = get_document_path(filename, language)
with open(document_path, "r", encoding="utf-8") as document_file:
markdown_text = document_file.read()
markdown_text = rewrite_manual_asset_paths(markdown_text, language)
tokens = renderer.parse(markdown_text)
toc = add_heading_ids_and_toc(tokens)
html = renderer.renderer.render(tokens, renderer.options, {})
return Markup(html), toc
def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown user manual."""
return render_markdown_document(USER_MANUAL_FILENAME, language)
def render_recent_changes(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown recent changes document."""
return render_markdown_document(RECENT_CHANGES_FILENAME, language)
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@auth.login_required @auth.login_required
def index(): def index():
@@ -127,6 +312,92 @@ def index():
log_access(auth.current_user(), "/", "GET") log_access(auth.current_user(), "/", "GET")
return render_template("roll_calculator.html") return render_template("roll_calculator.html")
@app.route("/help", methods=["GET"])
@auth.login_required
def help_index():
"""Help landing page - requires authentication."""
log_access(auth.current_user(), "/help", "GET")
return render_template("help_index.html")
@app.route("/help/user-manual", methods=["GET"])
@auth.login_required
def user_manual():
"""Render the Markdown user manual inside the RollCalc layout."""
log_access(auth.current_user(), "/help/user-manual", "GET")
try:
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=manual_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render user manual")
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=None,
toc=[],
render_error=(
"The user manual could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/recent-changes", methods=["GET"])
@auth.login_required
def recent_changes():
"""Render recent changes inside the RollCalc Help layout."""
log_access(auth.current_user(), "/help/recent-changes", "GET")
try:
changes_html, toc = render_recent_changes(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
error_title="Recent Changes unavailable",
manual_html=changes_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render recent changes")
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
error_title="Recent Changes unavailable",
manual_html=None,
toc=[],
render_error=(
"Recent changes could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"])
@auth.login_required
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
"""Serve user-manual screenshots from the selected language directory."""
log_access(
auth.current_user(),
f"/help/user-manual/screenshots/{filename}",
"GET"
)
try:
return send_from_directory(get_manual_screenshot_dir(language), filename)
except Exception:
app.logger.warning("Missing or inaccessible manual screenshot: %s", filename)
abort(404)
@app.route("/static/<path:filename>", methods=["GET"]) @app.route("/static/<path:filename>", methods=["GET"])
@auth.login_required @auth.login_required
def serve_static(filename): def serve_static(filename):
@@ -177,21 +448,22 @@ def internal_error(e):
if __name__ == "__main__": if __name__ == "__main__":
os.makedirs("templates", exist_ok=True) os.makedirs("templates", exist_ok=True)
os.makedirs("static", exist_ok=True) os.makedirs("static", exist_ok=True)
port = find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0")
print(""" print(f"""
╔═══════════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════════╗
║ Naue Roll Calculator - V14 + QoL Update ║ ║ Naue Roll Calculator - V14 + QoL Update ║
║ HTTP Basic Auth Enabled ║ ║ HTTP Basic Auth Enabled ║
║ ║ ║ ║
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║ ║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
║ ║ ║ ║
║ Starten auf: http://localhost:5000 ║ ║ Starten auf: http://localhost:{port:<5} ║
╚═══════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════╝
""") """)
app.run( app.run(
host="0.0.0.0", host="0.0.0.0",
port=5000, port=port,
debug=False, debug=False,
use_reloader=False use_reloader=False
) )
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"version": "v0.4-dev", "version": "v0.4-dev",
"branch": "develop/v0.4", "branch": "feature/transport-capacity-optimizer",
"commit": "7a27fb8", "commit": "216f53c",
"timestamp": "2026-07-07T16:46:32+02:00" "timestamp": "2026-07-28T13:02:23+02:00"
} }
+3
View File
@@ -0,0 +1,3 @@
{
"example-user": "pbkdf2:sha256:600000$example-salt$example-hash"
}
+39
View File
@@ -0,0 +1,39 @@
# Documentation Guidelines
## Language
The primary documentation language is German.
An English version will be maintained as a translation of the German master document.
## UI terminology
All user interface terms remain in English.
Examples:
- Direct Calculation
- Extrapolation
- Roll Diameter
- Product Length
- Capacity Summary
- Capacity Analysis
- Governing Constraint
Do not translate UI labels.
## Style
- Technical user manual
- Workflow-oriented
- Explain why, not only how
- Engineering language
- No marketing language
## Workflow
1. Write German chapter.
2. Technical review.
3. Add screenshots.
4. Approve.
5. Translate to English.
+9
View File
@@ -0,0 +1,9 @@
# Recent Changes
## Version 0.4
- User Manual integrated into RollCalc.
- Help area added for internal documentation.
- Capacity Analysis presentation improved.
- Governing Constraint highlighted more clearly.
- Remaining Capacity added to the Capacity Summary.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,3 +5,4 @@ click==8.1.7
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.3 MarkupSafe==2.1.3
markdown-it-py==3.0.0
Regular → Executable
+223 -41
View File
@@ -1,56 +1,238 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Interactive RollCalc user management."""
Create password hashes or user config snippets for RollCalc Basic Auth users.
"""
import argparse
import getpass
import json import json
import os
import stat
import sys
from getpass import getpass
from pathlib import Path
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
ROOT_DIR = Path(__file__).resolve().parents[1]
USER_FILE = ROOT_DIR / "config" / "users.json"
HASH_METHOD = "pbkdf2:sha256:600000"
def build_parser():
parser = argparse.ArgumentParser( class UserManagementError(Exception):
description="Generate RollCalc password hashes or user config entries." """Expected user-management error."""
def ensure_user_file(path=USER_FILE):
"""Create an empty user file if needed."""
path = Path(path)
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}\n", encoding="utf-8")
set_restrictive_permissions(path)
def set_restrictive_permissions(path):
"""Set user-only read/write permissions where supported."""
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError as exc:
print(f"Warning: could not set restrictive permissions: {exc}")
def load_users(path=USER_FILE):
"""Load user hashes from JSON."""
path = Path(path)
ensure_user_file(path)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise UserManagementError(f"Invalid JSON in {path}: {exc}") from exc
except OSError as exc:
raise UserManagementError(f"Could not read {path}: {exc}") from exc
if not isinstance(data, dict):
raise UserManagementError(f"Invalid user file format in {path}: expected object")
users = {}
for username, password_hash in data.items():
if not isinstance(username, str) or not username.strip():
raise UserManagementError(f"Invalid username in {path}")
if not isinstance(password_hash, str) or not password_hash.strip():
raise UserManagementError(f"Invalid password hash for user {username!r}")
users[username.strip()] = password_hash.strip()
return users
def save_users(users, path=USER_FILE):
"""Persist user hashes to JSON."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
existing_mode = None
if path.exists():
try:
existing_mode = stat.S_IMODE(path.stat().st_mode)
except OSError:
existing_mode = None
temp_path = path.with_name(f".{path.name}.tmp")
try:
temp_path.write_text(
json.dumps(users, indent=2, sort_keys=True) + "\n",
encoding="utf-8"
) )
parser.add_argument( if existing_mode is not None:
"username", os.chmod(temp_path, existing_mode)
nargs="?", else:
help="Optional username for a generated user config snippet.", os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
os.replace(temp_path, path)
except OSError as exc:
try:
temp_path.unlink()
except OSError:
pass
raise UserManagementError(f"Could not write {path}: {exc}") from exc
if existing_mode is None:
set_restrictive_permissions(path)
def hash_password(password):
"""Hash a password with RollCalc's established hash method."""
return generate_password_hash(password, method=HASH_METHOD)
def add_user(users, username, password):
"""Add a user to a user dictionary."""
username = username.strip()
if not username:
raise UserManagementError("Username must not be empty.")
if username in users:
raise UserManagementError(f'User "{username}" already exists.')
users[username] = hash_password(password)
def update_password(users, username, password):
"""Update an existing user's password hash."""
username = username.strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
users[username] = hash_password(password)
def remove_user(users, username):
"""Remove an existing user."""
username = username.strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
del users[username]
def prompt_password_pair():
"""Read and validate a repeated password."""
password = getpass("Password: ")
repeat = getpass("Repeat password: ")
if password != repeat:
raise UserManagementError("Passwords do not match.")
if not password:
raise UserManagementError("Password must not be empty.")
return password
def list_users(users):
"""Print existing usernames without hashes."""
print("\nExisting users\n")
if not users:
print("(none)")
else:
for username in sorted(users):
print(f"- {username}")
print()
def create_user_interactive(users):
"""Create a user from prompts."""
username = input("Username: ").strip()
password = prompt_password_pair()
add_user(users, username, password)
print(f'User "{username}" created.')
def change_password_interactive(users):
"""Change a user's password from prompts."""
username = input("Username: ").strip()
password = prompt_password_pair()
update_password(users, username, password)
print(f'Password for "{username}" changed.')
def delete_user_interactive(users):
"""Delete a user after confirmation."""
username = input("Username: ").strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
confirmation = input(f'Delete user "{username}"? yes/no: ').strip().lower()
if confirmation != "yes":
print("Delete cancelled.")
return
remove_user(users, username)
print(f'User "{username}" deleted.')
def print_menu():
"""Print the main menu."""
print(
"\n"
"--------------------------------------------------\n\n"
"RollCalc User Management\n\n"
"1 - List users\n"
"2 - Create user\n"
"3 - Change password\n"
"4 - Delete user\n"
"5 - Exit\n\n"
"--------------------------------------------------"
) )
parser.add_argument(
"--password",
help="Password to hash. Omit to enter it securely via prompt.", def run_menu(path=USER_FILE):
) """Run the interactive menu."""
parser.add_argument( while True:
"--json", try:
action="store_true", users = load_users(path)
help="Output a JSON object for the user entry.", except UserManagementError as exc:
) print(f"Error: {exc}", file=sys.stderr)
return parser return 1
print_menu()
choice = input("Select option: ").strip()
try:
if choice == "1":
list_users(users)
continue
if choice == "2":
create_user_interactive(users)
elif choice == "3":
change_password_interactive(users)
elif choice == "4":
delete_user_interactive(users)
elif choice == "5":
print("Exit.")
return 0
else:
print("Unknown option.")
continue
save_users(users, path)
except UserManagementError as exc:
print(f"Error: {exc}")
def main(): def main():
args = build_parser().parse_args() """CLI entry point."""
password = args.password return run_menu()
if password is None:
password = getpass.getpass("Password: ")
password_hash = generate_password_hash(password)
if args.username:
entry = {args.username: {"password_hash": password_hash}}
if args.json:
print(json.dumps(entry, indent=2))
else:
print(f'"{args.username}": {{')
print(f' "password_hash": "{password_hash}"')
print("}")
else:
print(password_hash)
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())
+2 -1
View File
@@ -3,12 +3,13 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION="${1:-v0.4-dev}"
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
cat > build_info.json <<EOF cat > build_info.json <<EOF
{ {
"version": "v0.4-dev", "version": "$VERSION",
"branch": "$(git branch --show-current)", "branch": "$(git branch --show-current)",
"commit": "$(git rev-parse --short HEAD)", "commit": "$(git rev-parse --short HEAD)",
"timestamp": "$(date -Iseconds)" "timestamp": "$(date -Iseconds)"
+8
View File
@@ -0,0 +1,8 @@
{
"items": [
"User Manual integrated",
"Capacity Analysis improved",
"Governing Constraint clarified",
"Remaining Capacity added"
]
}
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc Help">
<title>RollCalc Help</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 980px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.help-container { max-width: 980px; margin: 30px auto; padding: 0 16px 60px; }
.help-intro { margin-bottom: 22px; line-height: 1.55; color: #4b555a; }
.help-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; }
.help-card { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.help-card h2 { color: #1F5438; font-size: 18px; margin-bottom: 10px; }
.help-card p { color: #4f5b60; font-size: 14px; line-height: 1.55; margin-bottom: 18px; }
.help-card a { display: inline-block; background: #2BAC70; color: #fff; text-decoration: none; border-radius: 6px; padding: 10px 16px; font-size: 14px; font-weight: 700; }
.help-card a:hover { background: #1F5438; }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 640px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>RollCalc Help</h1>
<p>Documentation for roll calculation and transport capacity analysis</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
</nav>
</div>
</header>
<main class="help-container">
<p class="help-intro">
This area provides internal technical documentation for RollCalc. The documents are rendered from the maintained Markdown sources.
</p>
<section class="help-grid" aria-label="Available help documents">
<article class="help-card">
<h2>User Manual</h2>
<p>
Workflow-oriented manual for roll calculation, handling evaluation, Load Optimizer usage, and interpretation of Capacity Summary and Capacity Analysis.
</p>
<a href="{{ url_for('user_manual') }}">Open User Manual</a>
</article>
<article class="help-card">
<h2>Recent Changes</h2>
<p>
Compact release notes for relevant user-facing changes in RollCalc.
</p>
<a href="{{ url_for('recent_changes') }}">Open Recent Changes</a>
</article>
</section>
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>
+242 -16
View File
@@ -9,8 +9,11 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; } body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
.header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); } .header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.header-inner { max-width: 900px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.header h1 { font-size: 24px; margin-bottom: 5px; } .header h1 { font-size: 24px; margin-bottom: 5px; }
.header p { font-size: 13px; opacity: 0.9; } .header p { font-size: 13px; opacity: 0.9; }
.header-help-link { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.header-help-link:hover { background: rgba(255,255,255,0.18); }
.container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; } .container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; }
.card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; } .card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; }
.card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; } .card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; }
@@ -143,6 +146,59 @@
background: #b8c7bf; background: #b8c7bf;
cursor: not-allowed; cursor: not-allowed;
} }
.whats-new-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(20, 28, 34, 0.55);
}
.whats-new-overlay.is-hidden { display: none; }
.whats-new-dialog {
width: min(520px, 100%);
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.whats-new-header {
padding: 16px 20px;
background: #1F5438;
color: #fff;
}
.whats-new-header h2 { font-size: 18px; margin: 0 0 4px; }
.whats-new-version { font-size: 12px; opacity: 0.88; }
.whats-new-content {
padding: 18px 20px 8px;
font-size: 14px;
line-height: 1.5;
color: #333;
}
.whats-new-content ul { margin: 8px 0 0 20px; }
.whats-new-content li { margin-bottom: 5px; }
.whats-new-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px 18px;
border-top: 1px solid #e0e8f0;
background: #f8fafa;
}
.whats-new-btn {
border: none;
border-radius: 6px;
padding: 9px 14px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.whats-new-btn-primary { background: #2BAC70; color: #fff; }
.whats-new-btn-primary:hover { background: #1F5438; }
.whats-new-btn-secondary { background: #fff; color: #1F5438; border: 1px solid #a8c8ba; }
.whats-new-btn-secondary:hover { background: #eef4f1; }
/* ============================================ /* ============================================
LOAD OPTIMIZER STYLES LOAD OPTIMIZER STYLES
@@ -289,6 +345,23 @@
border-left: 4px solid #2BAC70; border-left: 4px solid #2BAC70;
} }
.lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; } .lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; }
.lo-summary-section {
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid #e6ece9;
}
.lo-summary-section:first-of-type {
padding-top: 0;
margin-top: 0;
border-top: 0;
}
.lo-summary-section-title {
margin-bottom: 8px;
color: #1F5438;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.lo-summary-item { .lo-summary-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -297,9 +370,23 @@
font-size: 13px; font-size: 13px;
} }
.lo-summary-item:last-child { margin-bottom: 0; } .lo-summary-item:last-child { margin-bottom: 0; }
.lo-summary-item.lo-governing-summary {
background: #eef8f2;
border-left: 3px solid #2BAC70;
padding: 8px 10px;
border-radius: 4px;
}
.lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; } .lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; }
.lo-value-number { color: #1F5438; font-weight: bold; } .lo-value-number { color: #1F5438; font-weight: bold; }
.lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; } .lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; }
.lo-reserve-note {
margin-top: 4px;
color: #666;
font-size: 12px;
line-height: 1.35;
text-align: right;
max-width: 300px;
}
.lo-results-table { .lo-results-table {
background: white; background: white;
padding: 16px; padding: 16px;
@@ -315,10 +402,15 @@
font-weight: 600; font-weight: 600;
color: #333; color: #333;
border-bottom: 2px solid #2BAC70; border-bottom: 2px solid #2BAC70;
white-space: normal;
} }
.lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; } .lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; }
.lo-results-table tbody tr:hover { background: #f9f9f9; } .lo-results-table tbody tr:hover { background: #f9f9f9; }
.lo-results-table tbody tr.lo-recommended { background: #fffacd; font-weight: bold; } .lo-results-table tbody tr.lo-governing {
background: #eef8f2;
font-weight: 600;
border-left: 3px solid #2BAC70;
}
.lo-warning { .lo-warning {
background: #fff8e1; background: #fff8e1;
border: 1px solid #f0b429; border: 1px solid #f0b429;
@@ -396,6 +488,11 @@
} }
.lo-sideview-section.show { display: block; } .lo-sideview-section.show { display: block; }
.lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; } .lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; }
.lo-sideview-context {
margin: -6px 0 12px;
color: #666;
font-size: 12px;
}
.lo-sideview-wrap { .lo-sideview-wrap {
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden; background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden;
} }
@@ -407,6 +504,10 @@
.margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; } .margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; }
@media (max-width: 768px) { @media (max-width: 768px) {
.header { padding: 18px 16px; }
.header-inner { align-items: flex-start; }
.whats-new-actions { flex-direction: column; }
.whats-new-btn { width: 100%; }
.lo-accordion-header h3 { font-size: 14px; } .lo-accordion-header h3 { font-size: 14px; }
.lo-preset-buttons { flex-direction: column; } .lo-preset-buttons { flex-direction: column; }
.lo-preset-btn { width: 100%; } .lo-preset-btn { width: 100%; }
@@ -450,10 +551,31 @@
</div> </div>
</div> </div>
<div class="whats-new-overlay is-hidden" id="whatsNewOverlay" role="dialog" aria-modal="true" aria-labelledby="whatsNewTitle">
<div class="whats-new-dialog">
<div class="whats-new-header">
<h2 id="whatsNewTitle">What’s New</h2>
<div class="whats-new-version">Version: <span id="whatsNewVersion">–</span></div>
</div>
<div class="whats-new-content">
<ul id="whatsNewList"></ul>
</div>
<div class="whats-new-actions">
<button class="whats-new-btn whats-new-btn-secondary" id="whatsNewOpenChangesBtn" type="button">Open Recent Changes</button>
<button class="whats-new-btn whats-new-btn-primary" id="whatsNewContinueBtn" type="button">Continue</button>
</div>
</div>
</div>
<div class="header"> <div class="header">
<div class="header-inner">
<div>
<h1>Naue Roll Diameter Calculator</h1> <h1>Naue Roll Diameter Calculator</h1>
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p> <p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
</div> </div>
<a class="header-help-link" href="{{ url_for('user_manual') }}">User Manual</a>
</div>
</div>
<div class="container"> <div class="container">
<div class="card"> <div class="card">
@@ -807,11 +929,17 @@
<div class="lo-results" id="resultsContainer"> <div class="lo-results" id="resultsContainer">
<div class="lo-results-summary"> <div class="lo-results-summary">
<h4>Results:</h4> <h4>Capacity Summary:</h4>
<div class="lo-summary-item"> <div class="lo-summary-section">
<span><strong>Limiting Factor:</strong></span> <div class="lo-summary-section-title">Governing Constraint</div>
<div class="lo-summary-item lo-governing-summary">
<span><strong>Constraint:</strong></span>
<span id="limitingFactorValue" class="lo-value-highlight">–</span> <span id="limitingFactorValue" class="lo-value-highlight">–</span>
</div> </div>
</div>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Transport Result</div>
<div class="lo-summary-item"> <div class="lo-summary-item">
<span><strong>Total Rolls:</strong></span> <span><strong>Total Rolls:</strong></span>
<div style="text-align:right;"> <div style="text-align:right;">
@@ -822,23 +950,37 @@
<div class="lo-summary-item"> <div class="lo-summary-item">
<span><strong>Utilization:</strong></span> <span><strong>Utilization:</strong></span>
<div class="lo-util-group"> <div class="lo-util-group">
<div>Weight: <span id="weightUtilValue" class="lo-value-number">–</span> %</div> <div>Weight Utilization: <span id="weightUtilValue" class="lo-value-number">–</span> %</div>
<div>Volume: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div> <div>Volume Utilization: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div>
<div>Product: <span id="productM2Value" class="lo-value-number">–</span> m²</div> <div>Transported Product Area: <span id="productM2Value" class="lo-value-number">–</span> m²</div>
</div>
</div>
</div>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Remaining Capacity</div>
<div class="lo-summary-item">
<span><strong>Remaining Capacity:</strong></span>
<div class="lo-util-group">
<div>Remaining Payload: <span id="remainingPayloadValue" class="lo-value-number">–</span> kg</div>
<div>Unused Payload: <span id="unusedPayloadValue" class="lo-value-number">–</span> %</div>
<div>Unused Volume: <span id="unusedVolumeValue" class="lo-value-number">–</span> %</div>
<div id="capacityInterpretationValue" class="lo-reserve-note"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="lo-results-table"> <div class="lo-results-table">
<h4>Comparison Table:</h4> <h4>Capacity Analysis:</h4>
<table id="comparisonTable"> <table id="comparisonTable">
<thead> <thead>
<tr> <tr>
<th>Scenario</th> <th>Constraint</th>
<th>Rolls</th> <th>Max Rolls</th>
<th>kg</th> <th>Total Weight</th>
<th>m²</th> <th>Transported Area [m²]</th>
<th>Util. %</th> <th>Payload Util. %</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@@ -847,6 +989,7 @@
<div class="lo-sideview-section" id="sideViewSection"> <div class="lo-sideview-section" id="sideViewSection">
<h4>🔍 Front View – Cross-Section</h4> <h4>🔍 Front View – Cross-Section</h4>
<p class="lo-sideview-context">Cross-section of the actual loading configuration.</p>
<div class="lo-sideview-wrap"> <div class="lo-sideview-wrap">
<svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg"> <svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg">
<text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text> <text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text>
@@ -1350,6 +1493,14 @@ class LoadOptimizerUI {
showResults(res, maxW) { showResults(res, maxW) {
const m2PerRoll = this.optimizer.state.m2PerRoll || 0; const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
const remainingPayload = Math.max(0, maxW - res.finalWeight);
const unusedPayload = Math.max(0, 100 - res.utilizationWeight);
const unusedVolume = Math.max(0, 100 - res.utilizationVolume);
const interpretations = {
geometry: 'Geometry prevents loading additional rolls although payload capacity remains available.',
weight: 'Payload limit is reached before the available geometry or volume is fully used.',
volume: 'Volume capacity is reached before payload or geometric capacity is fully used.'
};
document.getElementById('limitingFactorValue').textContent = res.limiting.name; document.getElementById('limitingFactorValue').textContent = res.limiting.name;
document.getElementById('totalRollsValue').textContent = res.finalRolls; document.getElementById('totalRollsValue').textContent = res.finalRolls;
document.getElementById('rollsDetailValue').textContent = document.getElementById('rollsDetailValue').textContent =
@@ -1357,16 +1508,21 @@ class LoadOptimizerUI {
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight); document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume); document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0); document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
document.getElementById('remainingPayloadValue').textContent = this.optimizer.fmt(remainingPayload, 0);
document.getElementById('unusedPayloadValue').textContent = this.optimizer.fmt(unusedPayload);
document.getElementById('unusedVolumeValue').textContent = this.optimizer.fmt(unusedVolume);
document.getElementById('capacityInterpretationValue').textContent =
interpretations[res.limiting.type] || '';
const tbody = document.querySelector('#comparisonTable tbody'); const tbody = document.querySelector('#comparisonTable tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
res.scenarios.forEach(sc => { res.scenarios.forEach(sc => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const lim = (sc.type === res.limiting.type); const lim = (sc.type === res.limiting.type);
if (lim) tr.classList.add('lo-recommended'); if (lim) tr.classList.add('lo-governing');
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–'; const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–'; const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
tr.innerHTML = tr.innerHTML =
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+ '<td>'+ sc.name +(lim?' &rarr; Governing Constraint':'')+'</td>'+
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+ '<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+ '<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
'<td>'+ scM2 +'</td>'+ '<td>'+ scM2 +'</td>'+
@@ -1488,12 +1644,81 @@ document.addEventListener('DOMContentLoaded', () => {
window.loadOptimizerUI = new LoadOptimizerUI(); window.loadOptimizerUI = new LoadOptimizerUI();
}); });
const ROLLCALC_BUILD_VERSION = {{ build_info.version|tojson }};
const ROLLCALC_WHATS_NEW_ITEMS = {{ whats_new_summary|tojson }};
const ROLLCALC_LAST_SEEN_VERSION_KEY = 'rollcalc_last_seen_version';
function shouldShowWhatsNew(currentVersion, storedVersion) {
return !!currentVersion &&
currentVersion !== 'unknown' &&
currentVersion !== storedVersion;
}
function markWhatsNewSeen(currentVersion) {
if (!currentVersion || currentVersion === 'unknown') return;
try {
localStorage.setItem(ROLLCALC_LAST_SEEN_VERSION_KEY, currentVersion);
} catch (e) {
console.warn('Could not store RollCalc release note state', e);
}
}
function renderWhatsNewItems(items) {
const list = document.getElementById('whatsNewList');
if (!list) return;
list.innerHTML = '';
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li);
});
}
function maybeShowWhatsNew() {
const overlay = document.getElementById('whatsNewOverlay');
const versionValue = document.getElementById('whatsNewVersion');
const continueBtn = document.getElementById('whatsNewContinueBtn');
const openChangesBtn = document.getElementById('whatsNewOpenChangesBtn');
const items = Array.isArray(ROLLCALC_WHATS_NEW_ITEMS)
? ROLLCALC_WHATS_NEW_ITEMS
: [];
if (!overlay || !versionValue || !continueBtn || !openChangesBtn || items.length === 0) return;
let storedVersion = null;
try {
storedVersion = localStorage.getItem(ROLLCALC_LAST_SEEN_VERSION_KEY);
} catch (e) {
storedVersion = null;
}
if (!shouldShowWhatsNew(ROLLCALC_BUILD_VERSION, storedVersion)) return;
versionValue.textContent = ROLLCALC_BUILD_VERSION;
renderWhatsNewItems(items);
overlay.classList.remove('is-hidden');
continueBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
overlay.classList.add('is-hidden');
}, { once: true });
openChangesBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
window.location.href = {{ url_for('recent_changes')|tojson }};
}, { once: true });
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('disclaimerOverlay'); const overlay = document.getElementById('disclaimerOverlay');
const checkbox = document.getElementById('disclaimerAccepted'); const checkbox = document.getElementById('disclaimerAccepted');
const confirmBtn = document.getElementById('disclaimerConfirmBtn'); const confirmBtn = document.getElementById('disclaimerConfirmBtn');
if (!overlay || !checkbox || !confirmBtn) return; if (!overlay || !checkbox || !confirmBtn) {
maybeShowWhatsNew();
return;
}
checkbox.addEventListener('change', () => { checkbox.addEventListener('change', () => {
confirmBtn.disabled = !checkbox.checked; confirmBtn.disabled = !checkbox.checked;
@@ -1502,6 +1727,7 @@ document.addEventListener('DOMContentLoaded', () => {
confirmBtn.addEventListener('click', () => { confirmBtn.addEventListener('click', () => {
if (!checkbox.checked) return; if (!checkbox.checked) return;
overlay.classList.add('is-hidden'); overlay.classList.add('is-hidden');
maybeShowWhatsNew();
}); });
}); });
</script> </script>
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc {{ document_title }}">
<title>RollCalc {{ document_title }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 1180px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.manual-shell { max-width: 1180px; margin: 28px auto; padding: 0 16px 60px; }
.manual-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
.manual-toc { position: sticky; top: 18px; background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); max-height: calc(100vh - 36px); overflow: auto; }
.manual-toc h2 { color: #1F5438; font-size: 14px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0; }
.manual-toc ul { list-style: none; }
.manual-toc li { margin: 0 0 8px; }
.manual-toc a { color: #34443b; text-decoration: none; font-size: 13px; line-height: 1.35; }
.manual-toc a:hover { color: #1F5438; text-decoration: underline; }
.manual-toc .toc-level-2 { padding-left: 14px; }
.manual-content { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 34px 42px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); line-height: 1.62; font-size: 15px; }
.manual-content h1, .manual-content h2, .manual-content h3, .manual-content h4 { color: #1F5438; line-height: 1.25; scroll-margin-top: 18px; }
.manual-content h1 { font-size: 30px; margin: 0 0 24px; padding-bottom: 14px; border-bottom: 2px solid #dfe7e2; }
.manual-content h2 { font-size: 22px; margin: 34px 0 14px; padding-top: 4px; }
.manual-content h3 { font-size: 17px; margin: 26px 0 10px; color: #244b39; }
.manual-content h4 { font-size: 15px; margin: 20px 0 8px; color: #244b39; }
.manual-content p { margin: 0 0 14px; }
.manual-content ul, .manual-content ol { margin: 0 0 16px 24px; }
.manual-content li { margin: 5px 0; }
.manual-content a { color: #1F5438; font-weight: 600; }
.manual-content code { background: #eef4f1; border: 1px solid #d7e4de; border-radius: 4px; padding: 1px 4px; font-family: Consolas, 'Courier New', monospace; font-size: 0.92em; }
.manual-content pre { background: #f5f7f6; border: 1px solid #dfe7e2; border-radius: 6px; padding: 14px; margin: 16px 0; overflow-x: auto; }
.manual-content pre code { background: transparent; border: 0; padding: 0; }
.manual-content blockquote { margin: 18px 0; padding: 14px 18px; border-left: 4px solid #2BAC70; background: #f1f8f5; color: #34443b; }
.manual-content blockquote p:last-child, .manual-content blockquote ul:last-child { margin-bottom: 0; }
.manual-content table { width: 100%; border-collapse: collapse; margin: 18px 0; display: block; overflow-x: auto; }
.manual-content th, .manual-content td { border: 1px solid #d7e4de; padding: 9px 11px; text-align: left; vertical-align: top; }
.manual-content th { background: #eef4f1; color: #1F5438; }
.manual-content img { max-width: 100%; height: auto; border: 1px solid #dfe7e2; border-radius: 6px; background: #f8fafa; display: block; margin: 18px 0 8px; }
.manual-error { background: #fff; border: 1px solid #e6c7c7; border-left: 4px solid #b84a4a; border-radius: 8px; padding: 20px; color: #5a2d2d; box-shadow: 0 2px 12px rgba(0,0,0,0.05); }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 840px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
.manual-layout { grid-template-columns: 1fr; }
.manual-toc { position: static; max-height: none; }
.manual-content { padding: 24px 20px; }
.manual-content h1 { font-size: 25px; }
.manual-content h2 { font-size: 20px; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>{{ document_title }}</h1>
<p>{{ document_subtitle }}</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
<a href="{{ url_for('help_index') }}">Help</a>
</nav>
</div>
</header>
<main class="manual-shell">
{% if render_error %}
<section class="manual-error" role="alert">
<strong>{{ error_title }}</strong>
<p>{{ render_error }}</p>
</section>
{% else %}
<div class="manual-layout">
<nav class="manual-toc" aria-label="Table of contents">
<h2>Contents</h2>
<ul>
{% for entry in toc %}
<li class="toc-level-{{ entry.level }}">
<a href="#{{ entry.id }}">{{ entry.title }}</a>
</li>
{% endfor %}
</ul>
</nav>
<article class="manual-content">
{{ manual_html }}
</article>
</div>
{% endif %}
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>