mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-10 19:46:22 +00:00
Compare commits
3 Commits
0.50.19
...
API-OpenAP
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ab1ab6be | ||
|
|
f4f716fffa | ||
|
|
6e6136aaa7 |
@@ -280,7 +280,10 @@ Excel import is recommended - that way you can better organise tags/groups of we
|
|||||||
|
|
||||||
## API Support
|
## 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
|
## Support us
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,7 @@ class Import(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""Import a list of watched URLs."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
|
|
||||||
extras = {}
|
extras = {}
|
||||||
|
|
||||||
|
|||||||
@@ -13,18 +13,7 @@ class Notifications(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""Return Notification URL List."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
|
|
||||||
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
|
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
|
||||||
|
|
||||||
@@ -35,16 +24,7 @@ class Notifications(Resource):
|
|||||||
@auth.check_token
|
@auth.check_token
|
||||||
@expects_json(schema_create_notification_urls)
|
@expects_json(schema_create_notification_urls)
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""Create Notification URLs."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
|
|
||||||
json_data = request.get_json()
|
json_data = request.get_json()
|
||||||
notification_urls = json_data.get("notification_urls", [])
|
notification_urls = json_data.get("notification_urls", [])
|
||||||
@@ -71,16 +51,7 @@ class Notifications(Resource):
|
|||||||
@auth.check_token
|
@auth.check_token
|
||||||
@expects_json(schema_create_notification_urls)
|
@expects_json(schema_create_notification_urls)
|
||||||
def put(self):
|
def put(self):
|
||||||
"""
|
"""Replace Notification URLs."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
json_data = request.get_json()
|
json_data = request.get_json()
|
||||||
notification_urls = json_data.get("notification_urls", [])
|
notification_urls = json_data.get("notification_urls", [])
|
||||||
|
|
||||||
@@ -102,17 +73,7 @@ class Notifications(Resource):
|
|||||||
@auth.check_token
|
@auth.check_token
|
||||||
@expects_json(schema_delete_notification_urls)
|
@expects_json(schema_delete_notification_urls)
|
||||||
def delete(self):
|
def delete(self):
|
||||||
"""
|
"""Delete Notification URLs."""
|
||||||
@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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
json_data = request.get_json()
|
json_data = request.get_json()
|
||||||
urls_to_delete = json_data.get("notification_urls", [])
|
urls_to_delete = json_data.get("notification_urls", [])
|
||||||
|
|||||||
@@ -9,20 +9,7 @@ class Search(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""Search for watches by URL or title text."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
query = request.args.get('q', '').strip()
|
query = request.args.get('q', '').strip()
|
||||||
tag_limit = request.args.get('tag', '').strip()
|
tag_limit = request.args.get('tag', '').strip()
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
|
|||||||
@@ -10,22 +10,7 @@ class SystemInfo(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""Return system info."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
import time
|
import time
|
||||||
overdue_watches = []
|
overdue_watches = []
|
||||||
|
|
||||||
|
|||||||
@@ -20,21 +20,7 @@ class Tag(Resource):
|
|||||||
# curl http://localhost:5000/api/v1/tag/<string:uuid>
|
# curl http://localhost:5000/api/v1/tag/<string:uuid>
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self, uuid):
|
def get(self, uuid):
|
||||||
"""
|
"""Get data for a single tag/group, toggle notification muting, or recheck all."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
|
tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
|
||||||
if not tag:
|
if not tag:
|
||||||
@@ -65,17 +51,7 @@ class Tag(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def delete(self, uuid):
|
def delete(self, uuid):
|
||||||
"""
|
"""Delete a tag/group and remove it from all watches."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
if not self.datastore.data['settings']['application']['tags'].get(uuid):
|
if not self.datastore.data['settings']['application']['tags'].get(uuid):
|
||||||
abort(400, message='No tag exists with the UUID of {}'.format(uuid))
|
abort(400, message='No tag exists with the UUID of {}'.format(uuid))
|
||||||
|
|
||||||
@@ -92,19 +68,7 @@ class Tag(Resource):
|
|||||||
@auth.check_token
|
@auth.check_token
|
||||||
@expects_json(schema_update_tag)
|
@expects_json(schema_update_tag)
|
||||||
def put(self, uuid):
|
def put(self, uuid):
|
||||||
"""
|
"""Update tag information."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
|
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
|
||||||
if not tag:
|
if not tag:
|
||||||
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
|
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
|
||||||
@@ -118,15 +82,7 @@ class Tag(Resource):
|
|||||||
@auth.check_token
|
@auth.check_token
|
||||||
# Only cares for {'title': 'xxxx'}
|
# Only cares for {'title': 'xxxx'}
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""Create a single tag/group."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
|
|
||||||
json_data = request.get_json()
|
json_data = request.get_json()
|
||||||
title = json_data.get("title",'').strip()
|
title = json_data.get("title",'').strip()
|
||||||
@@ -145,28 +101,7 @@ class Tags(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""List tags/groups."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
result = {}
|
result = {}
|
||||||
for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
|
for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
|
||||||
result[uuid] = {
|
result[uuid] = {
|
||||||
|
|||||||
@@ -26,23 +26,7 @@ class Watch(Resource):
|
|||||||
# ?recheck=true
|
# ?recheck=true
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self, uuid):
|
def get(self, uuid):
|
||||||
"""
|
"""Get information about a single watch, recheck, pause, or mute."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||||
if not watch:
|
if not watch:
|
||||||
@@ -74,15 +58,7 @@ class Watch(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def delete(self, uuid):
|
def delete(self, uuid):
|
||||||
"""
|
"""Delete a watch and related history."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
if not self.datastore.data['watching'].get(uuid):
|
if not self.datastore.data['watching'].get(uuid):
|
||||||
abort(400, message='No watch exists with the UUID of {}'.format(uuid))
|
abort(400, message='No watch exists with the UUID of {}'.format(uuid))
|
||||||
|
|
||||||
@@ -92,19 +68,7 @@ class Watch(Resource):
|
|||||||
@auth.check_token
|
@auth.check_token
|
||||||
@expects_json(schema_update_watch)
|
@expects_json(schema_update_watch)
|
||||||
def put(self, uuid):
|
def put(self, uuid):
|
||||||
"""
|
"""Update watch information."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
watch = self.datastore.data['watching'].get(uuid)
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
if not watch:
|
if not watch:
|
||||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
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
|
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self, uuid):
|
def get(self, uuid):
|
||||||
"""
|
"""Get a list of all historical snapshots available for a watch."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
watch = self.datastore.data['watching'].get(uuid)
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
if not watch:
|
if not watch:
|
||||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||||
@@ -158,20 +106,7 @@ class WatchSingleHistory(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self, uuid, timestamp):
|
def get(self, uuid, timestamp):
|
||||||
"""
|
"""Get single snapshot from watch."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
watch = self.datastore.data['watching'].get(uuid)
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
if not watch:
|
if not watch:
|
||||||
abort(404, message=f"No watch exists with the UUID of {uuid}")
|
abort(404, message=f"No watch exists with the UUID of {uuid}")
|
||||||
@@ -204,19 +139,7 @@ class WatchFavicon(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self, uuid):
|
def get(self, uuid):
|
||||||
"""
|
"""Get favicon for a watch."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
watch = self.datastore.data['watching'].get(uuid)
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
if not watch:
|
if not watch:
|
||||||
abort(404, message=f"No watch exists with the UUID of {uuid}")
|
abort(404, message=f"No watch exists with the UUID of {uuid}")
|
||||||
@@ -251,16 +174,7 @@ class CreateWatch(Resource):
|
|||||||
@auth.check_token
|
@auth.check_token
|
||||||
@expects_json(schema_create_watch)
|
@expects_json(schema_create_watch)
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""Create a single watch."""
|
||||||
@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
|
|
||||||
"""
|
|
||||||
|
|
||||||
json_data = request.get_json()
|
json_data = request.get_json()
|
||||||
url = json_data['url'].strip()
|
url = json_data['url'].strip()
|
||||||
@@ -294,36 +208,7 @@ class CreateWatch(Resource):
|
|||||||
|
|
||||||
@auth.check_token
|
@auth.check_token
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""List watches."""
|
||||||
@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 = {}
|
list = {}
|
||||||
|
|
||||||
tag_limit = request.args.get('tag', '').lower()
|
tag_limit = request.args.get('tag', '').lower()
|
||||||
|
|||||||
@@ -1,9 +1,33 @@
|
|||||||
Directory of docs
|
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
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
12
docs/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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://<your login url>/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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<h4>API Documentation</h4>
|
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user