kopia lustrzana https://github.com/dgtlmoon/changedetection.io
				
				
				
			API - Use OpenAPI docs (#3384)
							rodzic
							
								
									8379fdb1f8
								
							
						
					
					
						commit
						3ae07ac633
					
				| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 = {}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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", [])
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 = []
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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] = {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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`
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											
												Plik diff jest za duży
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
						 | 
				
			
			@ -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())
 | 
			
		||||
										
											
												Plik diff jest za duży
												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>
 | 
			
		||||
		Ładowanie…
	
		Reference in New Issue