API - Use OpenAPI docs (#3384)

This commit is contained in:
dgtlmoon
2025-08-24 00:48:17 +02:00
committed by GitHub
parent 8379fdb1f8
commit 3ae07ac633
16 changed files with 2245 additions and 5618 deletions

View File

@@ -280,7 +280,10 @@ Excel import is recommended - that way you can better organise tags/groups of we
## API Support
Supports managing the website watch list [via our API](https://changedetection.io/docs/api_v1/index.html)
Full REST API for programmatic management of watches, tags, notifications and more.
- **[Interactive API Documentation](https://changedetection.io/docs/api_v1/index.html)** - Complete API reference with live testing
- **[OpenAPI Specification](docs/api-spec.yaml)** - Generate SDKs for any programming language
## Support us

View File

@@ -13,16 +13,7 @@ class Import(Resource):
@auth.check_token
def post(self):
"""
@api {post} /api/v1/import Import a list of watched URLs
@apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
@apiName Import
@apiGroup Import
@apiSuccess (200) {List} OK List of watch UUIDs added
@apiSuccess (500) {String} ERR Some other error
"""
"""Import a list of watched URLs."""
extras = {}

View File

@@ -13,18 +13,7 @@ class Notifications(Resource):
@auth.check_token
def get(self):
"""
@api {get} /api/v1/notifications Return Notification URL List
@apiDescription Return the Notification URL List from the configuration
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45"
HTTP/1.0 200
{
'notification_urls': ["notification-urls-list"]
}
@apiName Get
@apiGroup Notifications
"""
"""Return Notification URL List."""
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
@@ -35,16 +24,7 @@ class Notifications(Resource):
@auth.check_token
@expects_json(schema_create_notification_urls)
def post(self):
"""
@api {post} /api/v1/notifications Create Notification URLs
@apiDescription Add one or more notification URLs from the configuration
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/notifications/batch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
@apiName CreateBatch
@apiGroup Notifications
@apiSuccess (201) {Object[]} notification_urls List of added notification URLs
@apiError (400) {String} Invalid input
"""
"""Create Notification URLs."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
@@ -71,16 +51,7 @@ class Notifications(Resource):
@auth.check_token
@expects_json(schema_create_notification_urls)
def put(self):
"""
@api {put} /api/v1/notifications Replace Notification URLs
@apiDescription Replace all notification URLs with the provided list (can be empty)
@apiExample {curl} Example usage:
curl -X PUT http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
@apiName Replace
@apiGroup Notifications
@apiSuccess (200) {Object[]} notification_urls List of current notification URLs
@apiError (400) {String} Invalid input
"""
"""Replace Notification URLs."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
@@ -102,17 +73,7 @@ class Notifications(Resource):
@auth.check_token
@expects_json(schema_delete_notification_urls)
def delete(self):
"""
@api {delete} /api/v1/notifications Delete Notification URLs
@apiDescription Deletes one or more notification URLs from the configuration
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/notifications -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
@apiParam {String[]} notification_urls The notification URLs to delete.
@apiName Delete
@apiGroup Notifications
@apiSuccess (204) {String} OK Deleted
@apiError (400) {String} No matching notification URLs found.
"""
"""Delete Notification URLs."""
json_data = request.get_json()
urls_to_delete = json_data.get("notification_urls", [])

View File

@@ -9,20 +9,7 @@ class Search(Resource):
@auth.check_token
def get(self):
"""
@api {get} /api/v1/search Search for watches
@apiDescription Search watches by URL or title text
@apiExample {curl} Example usage:
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Search
@apiGroup Search
@apiQuery {String} q Search query to match against watch URLs and titles
@apiQuery {String} [tag] Optional name of tag to limit results (name not UUID)
@apiQuery {String} [partial] Allow partial matching of URL query
@apiSuccess (200) {Object} JSON Object containing matched watches
"""
"""Search for watches by URL or title text."""
query = request.args.get('q', '').strip()
tag_limit = request.args.get('tag', '').strip()
from changedetectionio.strtobool import strtobool

View File

@@ -10,22 +10,7 @@ class SystemInfo(Resource):
@auth.check_token
def get(self):
"""
@api {get} /api/v1/systeminfo Return system info
@apiDescription Return some info about the current system state
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
HTTP/1.0 200
{
'queue_size': 10 ,
'overdue_watches': ["watch-uuid-list"],
'uptime': 38344.55,
'watch_count': 800,
'version': "0.40.1"
}
@apiName Get Info
@apiGroup System Information
"""
"""Return system info."""
import time
overdue_watches = []

View File

@@ -20,21 +20,7 @@ class Tag(Resource):
# curl http://localhost:5000/api/v1/tag/<string:uuid>
@auth.check_token
def get(self, uuid):
"""
@api {get} /api/v1/tag/:uuid Single tag / group - Get data, toggle notification muting, recheck all.
@apiDescription Retrieve tag information, set notification_muted status, recheck all in tag.
@apiExampleRequest
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?recheck=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Tag
@apiGroup Group / Tag
@apiParam {uuid} uuid Tag unique ID.
@apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
@apiQuery {String} [recheck] = True, Queue all watches with this tag for recheck
@apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag
@apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag
"""
"""Get data for a single tag/group, toggle notification muting, or recheck all."""
from copy import deepcopy
tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
if not tag:
@@ -65,17 +51,7 @@ class Tag(Resource):
@auth.check_token
def delete(self, uuid):
"""
@api {delete} /api/v1/tag/:uuid Delete a tag / group and remove it from all watches
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {string}
OK
@apiParam {uuid} uuid Tag unique ID.
@apiName DeleteTag
@apiGroup Group / Tag
@apiSuccess (200) {String} OK Was deleted
"""
"""Delete a tag/group and remove it from all watches."""
if not self.datastore.data['settings']['application']['tags'].get(uuid):
abort(400, message='No tag exists with the UUID of {}'.format(uuid))
@@ -92,19 +68,7 @@ class Tag(Resource):
@auth.check_token
@expects_json(schema_update_tag)
def put(self, uuid):
"""
@api {put} /api/v1/tag/:uuid Update tag information
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}'
@apiExampleResponse {json} Response:
"OK"
@apiDescription Updates an existing tag using JSON
@apiParam {uuid} uuid Tag unique ID.
@apiName UpdateTag
@apiGroup Group / Tag
@apiSuccess (200) {String} OK Was updated
@apiSuccess (500) {String} ERR Some other error
"""
"""Update tag information."""
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
if not tag:
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
@@ -118,15 +82,7 @@ class Tag(Resource):
@auth.check_token
# Only cares for {'title': 'xxxx'}
def post(self):
"""
@api {post} /api/v1/watch Create a single tag / group
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}'
@apiName Create
@apiGroup Group / Tag
@apiSuccess (200) {String} OK Was created
@apiSuccess (500) {String} ERR Some other error
"""
"""Create a single tag/group."""
json_data = request.get_json()
title = json_data.get("title",'').strip()
@@ -145,28 +101,7 @@ class Tags(Resource):
@auth.check_token
def get(self):
"""
@api {get} /api/v1/tags List tags / groups
@apiDescription Return list of available tags / groups
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {json} Response:
{
"cc0cfffa-f449-477b-83ea-0caafd1dc091": {
"title": "Tech News",
"notification_muted": false,
"date_created": 1677103794
},
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
"title": "Shopping",
"notification_muted": true,
"date_created": 1676662819
}
}
@apiName ListTags
@apiGroup Group / Tag Management
@apiSuccess (200) {JSON} Short list of tags keyed by tag/group UUID
"""
"""List tags/groups."""
result = {}
for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
result[uuid] = {

View File

@@ -26,23 +26,7 @@ class Watch(Resource):
# ?recheck=true
@auth.check_token
def get(self, uuid):
"""
@api {get} /api/v1/watch/:uuid Single watch - get data, recheck, pause, mute.
@apiDescription Retrieve watch information and set muted/paused status, returns the FULL Watch JSON which can be used for any other PUT (update etc)
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=unmuted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?paused=unpaused" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Watch
@apiGroup Watch
@apiGroupDocOrder 0
@apiParam {uuid} uuid Watch unique ID.
@apiQuery {Boolean} [recheck] Recheck this watch `recheck=1`
@apiQuery {String} [paused] =`paused` or =`unpaused` , Sets the PAUSED state
@apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
@apiSuccess (200) {String} OK When paused/muted/recheck operation OR full JSON object of the watch
@apiSuccess (200) {JSON} WatchJSON JSON Full JSON object of the watch
"""
"""Get information about a single watch, recheck, pause, or mute."""
from copy import deepcopy
watch = deepcopy(self.datastore.data['watching'].get(uuid))
if not watch:
@@ -74,15 +58,7 @@ class Watch(Resource):
@auth.check_token
def delete(self, uuid):
"""
@api {delete} /api/v1/watch/:uuid Delete a watch and related history
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiParam {uuid} uuid Watch unique ID.
@apiName Delete
@apiGroup Watch
@apiSuccess (200) {String} OK Was deleted
"""
"""Delete a watch and related history."""
if not self.datastore.data['watching'].get(uuid):
abort(400, message='No watch exists with the UUID of {}'.format(uuid))
@@ -92,19 +68,7 @@ class Watch(Resource):
@auth.check_token
@expects_json(schema_update_watch)
def put(self, uuid):
"""
@api {put} /api/v1/watch/:uuid Update watch information
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "new list"}'
@apiExampleResponse {string} Example usage:
OK
@apiDescription Updates an existing watch using JSON, accepts the same structure as returned in <a href="#watch_GET">get single watch information</a>
@apiParam {uuid} uuid Watch unique ID.
@apiName Update a watch
@apiGroup Watch
@apiSuccess (200) {String} OK Was updated
@apiSuccess (500) {String} ERR Some other error
"""
"""Update watch information."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -128,23 +92,7 @@ class WatchHistory(Resource):
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history
@auth.check_token
def get(self, uuid):
"""
@api {get} /api/v1/watch/<string:uuid>/history Get a list of all historical snapshots available for a watch
@apiDescription Requires `uuid`, returns list
@apiGroupDocOrder 1
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
@apiExampleResponse {json} Response:
{
"1676649279": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/cb7e9be8258368262246910e6a2a4c30.txt",
"1677092785": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/e20db368d6fc633e34f559ff67bb4044.txt",
"1677103794": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/02efdd37dacdae96554a8cc85dc9c945.txt"
}
@apiName Get list of available stored snapshots for watch
@apiGroup Watch History
@apiSuccess (200) {JSON} List of keyed (by change date) paths to snapshot, use the key to <a href="#snapshots_GET">fetch a single snapshot</a>.
@apiSuccess (404) {String} ERR Not found
"""
"""Get a list of all historical snapshots available for a watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -158,20 +106,7 @@ class WatchSingleHistory(Resource):
@auth.check_token
def get(self, uuid, timestamp):
"""
@api {get} /api/v1/watch/<string:uuid>/history/<int:timestamp> Get single snapshot from watch
@apiDescription Requires watch `uuid` and `timestamp`. `timestamp` of "`latest`" for latest available snapshot, or <a href="#watch_history_GET">use the list returned here</a>
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
@apiExampleResponse {string} Closes matching snapshot text
Big bad fox flew over the moon at 2025-01-01 etc etc
@apiName Get single snapshot content
@apiGroup Snapshots
@apiGroupDocOrder 2
@apiParam {String} [html] Optional Set to =1 to return the last HTML (only stores last 2 snapshots, use `latest` as timestamp)
@apiSuccess (200) {String} OK
@apiSuccess (404) {String} ERR Not found
"""
"""Get single snapshot from watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
@@ -204,19 +139,7 @@ class WatchFavicon(Resource):
@auth.check_token
def get(self, uuid):
"""
@api {get} /api/v1/watch/<string:uuid>/favicon Get favicon for a watch.
@apiDescription Requires watch `uuid`, ,The favicon is the favicon which is available in the page watch overview list.
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/favicon -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {binary data}
JPEG...
@apiName Get latest Favicon
@apiGroup Favicon
@apiGroupDocOrder 3
@apiSuccess (200) {binary} Data ( Binary data of the favicon )
@apiSuccess (404) {String} ERR Not found
"""
"""Get favicon for a watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
@@ -251,16 +174,7 @@ class CreateWatch(Resource):
@auth.check_token
@expects_json(schema_create_watch)
def post(self):
"""
@api {post} /api/v1/watch Create a single watch
@apiDescription Requires atleast `url` set, can accept the same structure as <a href="#watch_GET">get single watch information</a> to create.
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}'
@apiName Create
@apiGroup Watch
@apiSuccess (200) {String} OK Was created
@apiSuccess (500) {String} ERR Some other error
"""
"""Create a single watch."""
json_data = request.get_json()
url = json_data['url'].strip()
@@ -294,36 +208,7 @@ class CreateWatch(Resource):
@auth.check_token
def get(self):
"""
@api {get} /api/v1/watch List watches
@apiDescription Return concise list of available watches and some very basic info
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {json} Response:
{
"6a4b7d5c-fee4-4616-9f43-4ac97046b595": {
"last_changed": 1677103794,
"last_checked": 1677103794,
"last_error": false,
"title": "",
"url": "http://www.quotationspage.com/random.php"
},
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
"last_changed": 0,
"last_checked": 1676662819,
"last_error": false,
"title": "QuickLook",
"url": "https://github.com/QL-Win/QuickLook/tags"
}
}
@apiParam {String} [recheck_all] Optional Set to =1 to force recheck of all watches
@apiParam {String} [tag] Optional name of tag to limit results
@apiName ListWatches
@apiGroup Watch Management
@apiGroupDocOrder 4
@apiSuccess (200) {String} OK JSON dict
"""
"""List watches."""
list = {}
tag_limit = request.args.get('tag', '').lower()

View File

@@ -1,9 +1,33 @@
Directory of docs
To regenerate API docs
## Regenerating API Documentation
Run from this directory.
### Modern Interactive API Docs (Recommended)
To regenerate the modern API documentation, run from the `docs/` directory:
```bash
# Install dependencies (first time only)
npm install
# Generate the HTML documentation from OpenAPI spec using Redoc
npm run build-docs
```
### OpenAPI Specification
The OpenAPI specification (`docs/api-spec.yaml`) is the source of truth for API documentation. This industry-standard format enables:
- **Interactive documentation** - Test endpoints directly in the browser
- **SDK generation** - Auto-generate client libraries for any programming language
- **API validation** - Ensure code matches documentation
- **Integration tools** - Import into Postman, Insomnia, API gateways, etc.
**Important:** When adding or modifying API endpoints, you must update `docs/api-spec.yaml` to keep documentation in sync:
1. Edit `docs/api-spec.yaml` with new endpoints, parameters, or response schemas
2. Run `npm run build-docs` to regenerate the HTML documentation
3. Commit both the YAML spec and generated HTML files
`python3 python-apidoc/apidoc.py -i ../changedetectionio -o api_v1/index.html`

1266
docs/api-spec.yaml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

12
docs/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "changedetection-api-docs",
"version": "1.0.0",
"description": "API documentation generation for changedetection.io",
"private": true,
"scripts": {
"build-docs": "redocly build-docs api-spec.yaml --output api_v1/index.html"
},
"devDependencies": {
"@redocly/cli": "^1.34.5"
}
}

View File

@@ -1,397 +0,0 @@
#!/usr/bin/env python3
"""
Python API Documentation Generator
Parses @api comments from Python files and generates Bootstrap HTML docs
"""
import re
import os
import json
import argparse
from pathlib import Path
from dataclasses import dataclass, field
from typing import List, Dict, Any
from jinja2 import Template
@dataclass
class ApiEndpoint:
method: str = ""
url: str = ""
title: str = ""
name: str = ""
group: str = "General"
group_order: int = 999 # Default to high number (low priority)
group_doc_order: int = 999 # Default to high number (low priority) for sidebar ordering
description: str = ""
params: List[Dict[str, Any]] = field(default_factory=list)
query: List[Dict[str, Any]] = field(default_factory=list)
success: List[Dict[str, Any]] = field(default_factory=list)
error: List[Dict[str, Any]] = field(default_factory=list)
example: str = ""
example_request: str = ""
example_response: str = ""
def prettify_json(text: str) -> str:
"""Attempt to prettify JSON content in the text"""
if not text or not text.strip():
return text
# First, try to parse the entire text as JSON
stripped_text = text.strip()
try:
json_obj = json.loads(stripped_text)
return json.dumps(json_obj, indent=2, ensure_ascii=False)
except (json.JSONDecodeError, ValueError):
pass
# If that fails, try to find JSON blocks within the text
lines = text.split('\n')
prettified_lines = []
i = 0
while i < len(lines):
line = lines[i]
stripped_line = line.strip()
# Look for the start of a JSON object or array
if stripped_line.startswith('{') or stripped_line.startswith('['):
# Try to collect a complete JSON block
json_lines = [stripped_line]
brace_count = stripped_line.count('{') - stripped_line.count('}')
bracket_count = stripped_line.count('[') - stripped_line.count(']')
j = i + 1
while j < len(lines) and (brace_count > 0 or bracket_count > 0):
next_line = lines[j].strip()
json_lines.append(next_line)
brace_count += next_line.count('{') - next_line.count('}')
bracket_count += next_line.count('[') - next_line.count(']')
j += 1
# Try to parse and prettify the collected JSON block
json_block = '\n'.join(json_lines)
try:
json_obj = json.loads(json_block)
prettified = json.dumps(json_obj, indent=2, ensure_ascii=False)
prettified_lines.append(prettified)
i = j # Skip the lines we just processed
continue
except (json.JSONDecodeError, ValueError):
# If parsing failed, just add the original line
prettified_lines.append(line)
else:
prettified_lines.append(line)
i += 1
return '\n'.join(prettified_lines)
class ApiDocParser:
def __init__(self):
self.patterns = {
'api': re.compile(r'@api\s*\{(\w+)\}\s*([^\s]+)\s*(.*)'),
'apiName': re.compile(r'@apiName\s+(.*)'),
'apiGroup': re.compile(r'@apiGroup\s+(.*)'),
'apiGroupOrder': re.compile(r'@apiGroupOrder\s+(\d+)'),
'apiGroupDocOrder': re.compile(r'@apiGroupDocOrder\s+(\d+)'),
'apiDescription': re.compile(r'@apiDescription\s+(.*)'),
'apiParam': re.compile(r'@apiParam\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
'apiQuery': re.compile(r'@apiQuery\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
'apiSuccess': re.compile(r'@apiSuccess\s*\((\d+)\)\s*\{([^}]+)\}\s*(\w+)?\s*(.*)'),
'apiError': re.compile(r'@apiError\s*\((\d+)\)\s*\{([^}]+)\}\s*(.*)'),
'apiExample': re.compile(r'@apiExample\s*\{([^}]+)\}\s*(.*)'),
'apiExampleRequest': re.compile(r'@apiExampleRequest\s*\{([^}]+)\}\s*(.*)'),
'apiExampleResponse': re.compile(r'@apiExampleResponse\s*\{([^}]+)\}\s*(.*)'),
}
def parse_file(self, file_path: Path) -> List[ApiEndpoint]:
"""Parse a single Python file for @api comments"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Error reading {file_path}: {e}")
return []
endpoints = []
current_endpoint = None
in_multiline_example = False
in_multiline_request = False
in_multiline_response = False
example_lines = []
request_lines = []
response_lines = []
for line in content.split('\n'):
line_stripped = line.strip()
# Handle multiline examples, requests, and responses
if in_multiline_example or in_multiline_request or in_multiline_response:
# Check if this line starts a new example type or exits multiline mode
should_exit_multiline = False
if line_stripped.startswith('@apiExampleRequest'):
# Finalize current multiline block and start request
should_exit_multiline = True
elif line_stripped.startswith('@apiExampleResponse'):
# Finalize current multiline block and start response
should_exit_multiline = True
elif line_stripped.startswith('@apiExample'):
# Finalize current multiline block and start example
should_exit_multiline = True
elif line_stripped.startswith('@api') and not any(x in line_stripped for x in ['@apiExample', '@apiExampleRequest', '@apiExampleResponse']):
# Exit multiline mode for any other @api directive
should_exit_multiline = True
if should_exit_multiline:
# Finalize any active multiline blocks
if in_multiline_example and current_endpoint and example_lines:
current_endpoint.example = '\n'.join(example_lines)
if in_multiline_request and current_endpoint and request_lines:
current_endpoint.example_request = '\n'.join(request_lines)
if in_multiline_response and current_endpoint and response_lines:
raw_response = '\n'.join(response_lines)
current_endpoint.example_response = prettify_json(raw_response)
# Reset all multiline states
in_multiline_example = False
in_multiline_request = False
in_multiline_response = False
example_lines = []
request_lines = []
response_lines = []
# If this is still an example directive, continue processing it
if not (line_stripped.startswith('@apiExample') or line_stripped.startswith('@apiExampleRequest') or line_stripped.startswith('@apiExampleResponse')):
# This is a different @api directive, let it be processed normally
pass
# If it's an example directive, it will be processed below
else:
# For multiline blocks, preserve the content more liberally
# Remove leading comment markers but preserve structure
clean_line = re.sub(r'^\s*[#*/]*\s?', '', line)
# Add the line if it has content or if it's an empty line (for formatting)
if clean_line or not line_stripped:
if in_multiline_example:
example_lines.append(clean_line)
elif in_multiline_request:
request_lines.append(clean_line)
elif in_multiline_response:
response_lines.append(clean_line)
continue
# Skip non-comment lines
if not any(marker in line_stripped for marker in ['@api', '#', '*', '//']):
continue
# Extract @api patterns
for pattern_name, pattern in self.patterns.items():
match = pattern.search(line_stripped)
if match:
if pattern_name == 'api':
# Start new endpoint
if current_endpoint:
endpoints.append(current_endpoint)
current_endpoint = ApiEndpoint()
current_endpoint.method = match.group(1).lower()
current_endpoint.url = match.group(2)
current_endpoint.title = match.group(3).strip()
elif current_endpoint:
if pattern_name == 'apiName':
current_endpoint.name = match.group(1)
elif pattern_name == 'apiGroup':
current_endpoint.group = match.group(1)
elif pattern_name == 'apiGroupOrder':
current_endpoint.group_order = int(match.group(1))
elif pattern_name == 'apiGroupDocOrder':
current_endpoint.group_doc_order = int(match.group(1))
elif pattern_name == 'apiDescription':
current_endpoint.description = match.group(1)
elif pattern_name == 'apiParam':
param_type = match.group(1)
param_name = match.group(2).strip('[]')
param_desc = match.group(3)
optional = '[' in match.group(2)
current_endpoint.params.append({
'type': param_type,
'name': param_name,
'description': param_desc,
'optional': optional
})
elif pattern_name == 'apiQuery':
param_type = match.group(1)
param_name = match.group(2).strip('[]')
param_desc = match.group(3)
optional = '[' in match.group(2)
current_endpoint.query.append({
'type': param_type,
'name': param_name,
'description': param_desc,
'optional': optional
})
elif pattern_name == 'apiSuccess':
status_code = match.group(1)
response_type = match.group(2)
response_name = match.group(3) or 'response'
response_desc = match.group(4)
current_endpoint.success.append({
'status': status_code,
'type': response_type,
'name': response_name,
'description': response_desc
})
elif pattern_name == 'apiError':
status_code = match.group(1)
error_type = match.group(2)
error_desc = match.group(3)
current_endpoint.error.append({
'status': status_code,
'type': error_type,
'description': error_desc
})
elif pattern_name == 'apiExample':
in_multiline_example = True
# Skip the "{curl} Example usage:" header line
example_lines = []
elif pattern_name == 'apiExampleRequest':
in_multiline_request = True
# Skip the "{curl} Request:" header line
request_lines = []
elif pattern_name == 'apiExampleResponse':
in_multiline_response = True
# Skip the "{json} Response:" header line
response_lines = []
break
# Don't forget the last endpoint
if current_endpoint:
if in_multiline_example and example_lines:
current_endpoint.example = '\n'.join(example_lines)
if in_multiline_request and request_lines:
current_endpoint.example_request = '\n'.join(request_lines)
if in_multiline_response and response_lines:
raw_response = '\n'.join(response_lines)
current_endpoint.example_response = prettify_json(raw_response)
endpoints.append(current_endpoint)
return endpoints
def parse_directory(self, directory: Path) -> List[ApiEndpoint]:
"""Parse all Python files in a directory"""
all_endpoints = []
for py_file in directory.rglob('*.py'):
endpoints = self.parse_file(py_file)
all_endpoints.extend(endpoints)
return all_endpoints
def generate_html(endpoints: List[ApiEndpoint], output_file: Path, template_file: Path):
"""Generate HTML documentation using Jinja2 template"""
# Group endpoints by group and collect group orders
grouped_endpoints = {}
group_orders = {}
group_doc_orders = {}
for endpoint in endpoints:
group = endpoint.group
if group not in grouped_endpoints:
grouped_endpoints[group] = []
group_orders[group] = endpoint.group_order
group_doc_orders[group] = endpoint.group_doc_order
grouped_endpoints[group].append(endpoint)
# Use the lowest order value for the group (in case of multiple definitions)
group_orders[group] = min(group_orders[group], endpoint.group_order)
group_doc_orders[group] = min(group_doc_orders[group], endpoint.group_doc_order)
# Sort groups by doc order for sidebar (0 = highest priority), then by content order, then alphabetically
sorted_groups = sorted(grouped_endpoints.items(), key=lambda x: (group_doc_orders[x[0]], group_orders[x[0]], x[0]))
# Convert back to ordered dict and sort endpoints within each group
grouped_endpoints = {}
for group, endpoints_list in sorted_groups:
endpoints_list.sort(key=lambda x: (x.name, x.url))
grouped_endpoints[group] = endpoints_list
# Load template
with open(template_file, 'r', encoding='utf-8') as f:
template_content = f.read()
# Load introduction content
introduction_file = template_file.parent / 'introduction.html'
introduction_content = ""
if introduction_file.exists():
with open(introduction_file, 'r', encoding='utf-8') as f:
introduction_content = f.read()
# Load sidebar header content
sidebar_header_file = template_file.parent / 'sidebar-header.html'
sidebar_header_content = "<h4>API Documentation</h4>" # Default fallback
if sidebar_header_file.exists():
with open(sidebar_header_file, 'r', encoding='utf-8') as f:
sidebar_header_content = f.read()
template = Template(template_content)
html_content = template.render(
grouped_endpoints=grouped_endpoints,
introduction_content=introduction_content,
sidebar_header_content=sidebar_header_content
)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_content)
def main():
parser = argparse.ArgumentParser(description='Generate API documentation from Python source files')
parser.add_argument('-i', '--input', default='.',
help='Input directory to scan for Python files (default: current directory)')
parser.add_argument('-o', '--output', default='api_docs.html',
help='Output HTML file (default: api_docs.html)')
parser.add_argument('-t', '--template', default='template.html',
help='Template HTML file (default: template.html)')
args = parser.parse_args()
input_path = Path(args.input)
output_path = Path(args.output)
template_path = Path(args.template)
# Make template path relative to script location if not absolute
if not template_path.is_absolute():
template_path = Path(__file__).parent / template_path
if not input_path.exists():
print(f"Error: Input directory '{input_path}' does not exist")
return 1
if not template_path.exists():
print(f"Error: Template file '{template_path}' does not exist")
return 1
print(f"Scanning {input_path} for @api comments...")
doc_parser = ApiDocParser()
endpoints = doc_parser.parse_directory(input_path)
if not endpoints:
print("No API endpoints found!")
return 1
print(f"Found {len(endpoints)} API endpoints")
# Create output directory if needed
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Generating HTML documentation to {output_path}...")
generate_html(endpoints, output_path, template_path)
print("Documentation generated successfully!")
print(f"Open {output_path.resolve()} in your browser to view the docs")
return 0
if __name__ == '__main__':
exit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
<div class="introduction-content">
<h3>ChangeDetection.io, Web page monitoring and notifications API</h3>
<p>REST API for managing Page watches, Group tags, and Notifications.</p>
<p>changedetection.io can be driven by its built in simple API, in the examples below you will also find <code>curl</code> command line examples to help you.</p>
<p>
<h5>Where to find my API key?</h5>
The API key can be easily found under the <strong>SETTINGS</strong> then <strong>API</strong> tab of changedetection.io dashboard.<br>
Simply click the API key to automatically copy it to your clipboard.<br><br>
<img src="where-to-get-api-key.jpeg" alt="Where to find the API key" title="Where to find the API key" style="max-width: 80%"/>
</p>
<p>
<h5>Connection URL</h5>
The API can be found at <code>/api/v1/</code>, so for example if you run changedetection.io locally on port 5000, then URL would be
<code>http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code>.<br><br>
If you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example.<br>
<code>https://&lt;your login url&gt;/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code>
</p>
<p>
<h5>Authentication</h5>
Almost all API requests require some authentication, this is provided as an <strong>API Key</strong> in the header of the HTTP request.<br><br>
For example;
<br><code>x-api-key: YOUR_API_KEY</code><br>
</p>
</div>

View File

@@ -1 +0,0 @@
<h4>API Documentation</h4>

View File

@@ -1,506 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet">
<style>
body { background-color: #f8f9fa; }
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; background: white; box-shadow: 2px 0 5px rgba(0,0,0,0.1); }
.content { padding: 20px; }
.endpoint { margin-bottom: 40px; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.method { font-weight: bold; text-transform: uppercase; padding: 4px 8px; border-radius: 4px; }
.method.get { background: #d4edda; color: #155724; }
.method.post { background: #cce5ff; color: #004085; }
.method.put { background: #fff3cd; color: #856404; }
.method.delete { background: #f8d7da; color: #721c24; }
.param-table { font-size: 0.9em; }
.optional { color: #6c757d; font-style: italic; }
.example { background: #f8f9fa; border-left: 4px solid #007bff; }
pre { font-size: 0.85em; }
.copy-btn { opacity: 0.7; transition: opacity 0.2s ease; }
.copy-btn:hover { opacity: 1; }
.example:hover .copy-btn { opacity: 1; }
.nav-link.active { background-color: #007bff; color: white; font-weight: bold; }
.nav-link { transition: all 0.2s ease; }
.nav-link:hover:not(.active) { background-color: #e3f2fd; color: #0056b3; }
.group-header.active { font-weight: bold; color: #007bff; }
/* Custom scrollbar styling */
.sidebar::-webkit-scrollbar { width: 8px; }
.sidebar::-webkit-scrollbar-track { background: #f8f9fa; border-radius: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: #dee2e6; border-radius: 4px; }
.sidebar::-webkit-scrollbar-thumb:hover { background: #adb5bd; }
/* Firefox scrollbar */
.sidebar { scrollbar-width: thin; scrollbar-color: #dee2e6 #f8f9fa; }
/* Mobile styles - disable sticky sidebar */
@media (max-width: 800px) {
.sidebar {
position: static !important;
height: auto !important;
overflow-y: visible !important;
}
}
</style>
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<div class="col-md-3 sidebar">
<div class="p-3">
<a href="#introduction" class="text-decoration-none">
{{ sidebar_header_content|safe }}
</a>
<hr>
{% if introduction_content %}
<div class="mb-3">
<a href="#introduction" class="text-decoration-none">
<h6 class="text-muted">Introduction</h6>
</a>
</div>
{% endif %}
{% for group, endpoints in grouped_endpoints.items() %}
<div class="mb-3">
<h6 class="text-muted group-header" data-group="{{ group }}">{{ group }}</h6>
{% for endpoint in endpoints %}
<div class="ms-2 mb-1">
<a href="#{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}" class="nav-link py-1 px-2 rounded" data-endpoint="{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}">
<span class="method {{ endpoint.method }}">{{ endpoint.method }}</span>
{{ endpoint.title or endpoint.name or endpoint.description }}
</a>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<!-- Main content -->
<div class="col-md-9 content">
{% if introduction_content %}
<div id="introduction" class="mb-5">
{{ introduction_content|safe }}
</div>
{% endif %}
{% for group, endpoints in grouped_endpoints.items() %}
<h2 class="text-primary mb-4" id="group-{{ group|replace(' ', '_')|lower }}">{{ group }}</h2>
{% for endpoint in endpoints %}
<div class="endpoint" id="{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}" data-group="{{ group }}">
<div class="row">
<div class="col-md-8">
<h4>
<span class="method {{ endpoint.method }}">{{ endpoint.method }}</span>
<code>{{ endpoint.url|e }}</code>
</h4>
<h5 class="text-muted">{{ endpoint.name or endpoint.title }}</h5>
{% if endpoint.description %}
<p class="mt-3">{{ endpoint.description|safe }}</p>
{% endif %}
</div>
</div>
{% if endpoint.params %}
<h6 class="mt-4">Parameters</h6>
<div class="table-responsive">
<table class="table table-sm param-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for param in endpoint.params %}
<tr>
<td><code>{{ param.name }}</code></td>
<td><span class="badge bg-secondary">{{ param.type }}</span></td>
<td>{% if param.optional %}<span class="optional">Optional</span>{% else %}<span class="text-danger">Required</span>{% endif %}</td>
<td>{{ param.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if endpoint.query %}
<h6 class="mt-4">Query Parameters</h6>
<div class="table-responsive">
<table class="table table-sm param-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for param in endpoint.query %}
<tr>
<td><code>{{ param.name }}</code></td>
<td><span class="badge bg-info">{{ param.type }}</span></td>
<td>{% if param.optional %}<span class="optional">Optional</span>{% else %}<span class="text-danger">Required</span>{% endif %}</td>
<td>{{ param.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if endpoint.success %}
<h6 class="mt-4">Success Responses</h6>
{% for success in endpoint.success %}
<div class="mb-2">
<span class="badge bg-success">{{ success.status }}</span>
<span class="badge bg-secondary ms-1">{{ success.type }}</span>
<strong class="ms-2">{{ success.name }}</strong>
<span class="ms-2 text-muted">{{ success.description }}</span>
</div>
{% endfor %}
{% endif %}
{% if endpoint.error %}
<h6 class="mt-4">Error Responses</h6>
{% for error in endpoint.error %}
<div class="mb-2">
<span class="badge bg-danger">{{ error.status }}</span>
<span class="badge bg-secondary ms-1">{{ error.type }}</span>
<span class="ms-2 text-muted">{{ error.description }}</span>
</div>
{% endfor %}
{% endif %}
{% if endpoint.example or endpoint.example_request or endpoint.example_response %}
<h6 class="mt-4">Example</h6>
{% if endpoint.example_request %}
<h7 class="mt-3 mb-2 text-muted">Request</h7>
<div class="example p-3 rounded position-relative mb-3">
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
data-bs-toggle="tooltip"
data-bs-placement="left"
title="Copy to clipboard"
onclick="copyToClipboard(this)">
<i class="bi bi-clipboard" aria-hidden="true"></i>
</button>
<pre><code class="language-bash">{{ endpoint.example_request }}</code></pre>
</div>
{% endif %}
{% if endpoint.example_response %}
<h7 class="mt-3 mb-2 text-muted">Response</h7>
<div class="example p-3 rounded position-relative mb-3">
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
data-bs-toggle="tooltip"
data-bs-placement="left"
title="Copy to clipboard"
onclick="copyToClipboard(this)">
<i class="bi bi-clipboard" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{{ endpoint.example_response }}</code></pre>
</div>
{% endif %}
{% if endpoint.example and not endpoint.example_request and not endpoint.example_response %}
<div class="example p-3 rounded position-relative">
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
data-bs-toggle="tooltip"
data-bs-placement="left"
title="Copy to clipboard"
onclick="copyToClipboard(this)">
<i class="bi bi-clipboard" aria-hidden="true"></i>
</button>
<pre><code class="language-bash">{{ endpoint.example }}</code></pre>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script>
$(document).ready(function() {
let isScrolling = false;
let isNavigating = false;
// Check if we should disable scroll handling on mobile
function isMobileWidth() {
return window.innerWidth < 800;
}
// Debounced scroll handler
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Function to scroll sidebar link into view if needed
function scrollIntoViewIfNeeded(element) {
if (!element) return;
const sidebar = $('.sidebar')[0];
const rect = element.getBoundingClientRect();
const sidebarRect = sidebar.getBoundingClientRect();
// Check if element is outside the sidebar viewport
const isAboveView = rect.top < sidebarRect.top;
const isBelowView = rect.bottom > sidebarRect.bottom;
if (isAboveView || isBelowView) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}
// Intersection Observer for more efficient viewport detection
const observerOptions = {
root: null,
rootMargin: '-20% 0px -70% 0px', // Trigger when element is in top 30% of viewport
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
// Don't update if user is actively navigating or on mobile
if (isNavigating || isMobileWidth()) return;
entries.forEach(entry => {
if (entry.isIntersecting) {
const targetId = entry.target.id;
const targetGroup = entry.target.dataset.group;
// Update window location hash
if (window.location.hash !== '#' + targetId) {
history.replaceState(null, null, '#' + targetId);
}
// Remove all active states
$('.nav-link').removeClass('active');
$('.group-header').removeClass('active');
// Add active state to current item
const $activeLink = $(`.nav-link[data-endpoint="${targetId}"]`);
$activeLink.addClass('active');
$(`.group-header[data-group="${targetGroup}"]`).addClass('active');
// Handle introduction section
if (targetId === 'introduction') {
const $introLink = $('a[href="#introduction"]');
$introLink.addClass('active');
// Scroll intro link into view in sidebar
scrollIntoViewIfNeeded($introLink[0]);
} else {
// Scroll active link into view in sidebar
if ($activeLink.length) {
scrollIntoViewIfNeeded($activeLink[0]);
}
}
}
});
}, observerOptions);
// Observe all endpoints and introduction (only on desktop)
if (!isMobileWidth()) {
$('.endpoint').each(function() {
observer.observe(this);
});
if ($('#introduction').length) {
observer.observe($('#introduction')[0]);
}
}
// Smooth scrolling for navigation links
$('a[href^="#"]').on('click', function(e) {
e.preventDefault();
const targetHref = this.getAttribute('href');
const target = $(targetHref);
if (target.length) {
// Set navigation flag to prevent observer interference
isNavigating = true;
// Update window location hash immediately
history.pushState(null, null, targetHref);
$('html, body').animate({
scrollTop: target.offset().top - 20
}, 300, function() {
// Clear navigation flag after animation completes
setTimeout(() => {
isNavigating = false;
}, 100);
});
}
});
// Fallback scroll handler with debouncing
const handleScroll = debounce(() => {
if (isScrolling || isNavigating || isMobileWidth()) return;
let current = '';
let currentGroup = '';
// Check which section is currently in view
$('.endpoint, #introduction').each(function() {
const element = $(this);
const elementTop = element.offset().top;
const elementBottom = elementTop + element.outerHeight();
const scrollTop = $(window).scrollTop() + 100; // Offset for better UX
if (scrollTop >= elementTop && scrollTop < elementBottom) {
current = this.id;
currentGroup = element.data('group');
return false; // Break loop
}
});
if (current) {
// Update window location hash
if (window.location.hash !== '#' + current) {
history.replaceState(null, null, '#' + current);
}
$('.nav-link').removeClass('active');
$('.group-header').removeClass('active');
const $activeLink = $(`.nav-link[data-endpoint="${current}"]`);
$activeLink.addClass('active');
if (currentGroup) {
$(`.group-header[data-group="${currentGroup}"]`).addClass('active');
}
if (current === 'introduction') {
const $introLink = $('a[href="#introduction"]');
$introLink.addClass('active');
scrollIntoViewIfNeeded($introLink[0]);
} else if ($activeLink.length) {
scrollIntoViewIfNeeded($activeLink[0]);
}
}
}, 50);
// Only bind scroll handler on desktop
if (!isMobileWidth()) {
$(window).on('scroll', handleScroll);
// Initial call
handleScroll();
}
// Initialize tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
});
// Copy to clipboard function
function copyToClipboard(button) {
const codeBlock = button.parentElement.querySelector('code');
const text = codeBlock.textContent;
// Use modern clipboard API
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
showCopyFeedback(button, true);
}).catch(() => {
fallbackCopyToClipboard(text, button);
});
} else {
fallbackCopyToClipboard(text, button);
}
}
// Fallback for older browsers
function fallbackCopyToClipboard(text, button) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
showCopyFeedback(button, successful);
} catch (err) {
showCopyFeedback(button, false);
}
document.body.removeChild(textArea);
}
// Show copy feedback
function showCopyFeedback(button, success) {
const icon = button.querySelector('i');
const originalClass = icon.className;
const originalTitle = button.getAttribute('data-bs-original-title') || button.getAttribute('title');
if (success) {
icon.className = 'bi bi-check';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-success');
button.setAttribute('title', 'Copied!');
} else {
icon.className = 'bi bi-x';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-danger');
button.setAttribute('title', 'Failed to copy');
}
// Update tooltip
const tooltip = bootstrap.Tooltip.getInstance(button);
if (tooltip) {
tooltip.dispose();
new bootstrap.Tooltip(button);
}
// Reset after 2 seconds
setTimeout(() => {
icon.className = originalClass;
button.classList.remove('btn-success', 'btn-danger');
button.classList.add('btn-outline-secondary');
button.setAttribute('title', originalTitle);
// Update tooltip again
const tooltip = bootstrap.Tooltip.getInstance(button);
if (tooltip) {
tooltip.dispose();
new bootstrap.Tooltip(button);
}
}, 2000);
}
</script>
</body>
</html>