├── .gitignore ├── LICENSE.md ├── README.md └── python_obsidian_api.py /.gitignore: -------------------------------------------------------------------------------- 1 | .secret.json 2 | .ipynb 3 | */__pycache__/ 4 | logs/* 5 | logs 6 | archive/* 7 | archive/ 8 | archive 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Evelyn Kai-Yan Liu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Obsidian API Python Wrapper 3 | 4 | This is a simple Python wrapper of the [Obsidian Local REST API](https://coddingtonbear.github.io/obsidian-local-rest-api/). 5 | 6 | ## Installation 7 | 8 | 1. Download the package: 9 | - git: 10 | `git clone https://github.com/evelynkyl/obsidian_python_api.git` 11 | - or wget: 12 | `wget --no-check-certificate --content-disposition https://github.com/evelynkyl/obsidian_python_api/archive/refs/heads/main.zip` 13 | - or curl: 14 | `curl -LJO https://github.com/evelynkyl/obsidian_python_api/archive/refs/heads/main.zip` 15 | 2. Install: 16 | `sudo python setup.py install` 17 | Or 18 | `pip install git+https://github.com/evelynkyl/obsidian_python_api.git` 19 | 20 | ## Usage 21 | 22 | ```python 23 | API_URL, API_Key = 'demo_url', 'demo_api_key' 24 | Obsidian = ObsidianFiles(API_URL, API_Key) 25 | 26 | # Get the content of the currently open file on Obsidian 27 | active_content = Obsidian._get_active_file_content() 28 | 29 | # Search your vault with a JSON / dataview query 30 | request_body = '''TABLE 31 | time-played AS "Time Played", 32 | length AS "Length", 33 | rating AS "Rating" 34 | FROM #game 35 | SORT rating DESC''' 36 | 37 | files_from_query = Obsidian._search_with_query(request_body) 38 | files_from_query 39 | ``` 40 | -------------------------------------------------------------------------------- /python_obsidian_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Doc: https://coddingtonbear.github.io/obsidian-local-rest-api/#/ 4 | 5 | import logging as logging 6 | import os 7 | from datetime import datetime 8 | from typing import Any, Dict, List 9 | 10 | from requests import Request, Session 11 | from requests.exceptions import HTTPError 12 | from urllib3 import disable_warnings 13 | from urllib3.exceptions import InsecureRequestWarning 14 | import json 15 | 16 | """ Logger set up """ 17 | # Set up a logger to log info to console and all messages to a log file 18 | for handler in logging.root.handlers[:]: 19 | logging.root.removeHandler(handler) 20 | 21 | # Get current working directory and create a new folder called log to save log files 22 | dir_path = os.path.dirname(os.path.realpath(__file__)) 23 | if not os.path.exists(dir_path + "/logs"): 24 | os.makedirs(dir_path + "/logs") 25 | 26 | logfile = ( 27 | f'{dir_path}/logs/obsidian_api_{datetime.now().strftime("%H_%M_%d_%m_%Y")}.log' 28 | ) 29 | 30 | logging.basicConfig( 31 | filename=logfile, 32 | level=logging.DEBUG, 33 | format="[%(asctime)s]%(levelname)s - %(message)s", 34 | datefmt="%H:%M:%S", 35 | filemode="w", 36 | ) 37 | 38 | # Set up logging to console 39 | console = logging.StreamHandler() 40 | console.setLevel(logging.INFO) 41 | # Set a format which is simpler for console use 42 | formatter = logging.Formatter("%(name)-12s: %(levelname)-8s %(message)s") 43 | console.setFormatter(formatter) 44 | # Add the handler to the root logger 45 | logging.getLogger("").addHandler(console) 46 | logger = logging.getLogger(__name__) 47 | 48 | disable_warnings(InsecureRequestWarning) 49 | 50 | 51 | class ObsidianFiles: 52 | def __init__( 53 | self, 54 | api_url: str, 55 | token: str, 56 | public_cert: str or None = None, 57 | public_key: str or None = None, 58 | ): 59 | self.api_url = api_url 60 | self.token = token 61 | self.headers = { 62 | "accept": "text/markdown", 63 | "Authorization": f"Bearer {self.token}", 64 | } 65 | 66 | self.cert = ( 67 | (public_cert, public_key) if public_cert and public_key else None 68 | ) # certifi.where() 69 | 70 | def _send_request(self, method: str, cmd: str, data: str or None = None) -> str: 71 | """Send an HTTP request to your local Obsidian server 72 | 73 | Args: 74 | method (str): HTTP method to send. Must be one of the following methods: 75 | - POST, GET, DELETE, PATCH, PUT 76 | cmd (str): Endpoint command to send. Must be one of the following endpoints: 77 | - active, vault, periodic, commands, search, open 78 | data (str or None, optional): Content to add to the target file. Defaults to None. 79 | 80 | Returns: 81 | str: The content in markdown format. 82 | """ 83 | s = Session() 84 | _request = ( 85 | Request( 86 | method, 87 | f"{self.api_url}{cmd}", 88 | headers=self.headers, 89 | data=data, 90 | ) 91 | if data 92 | else Request( 93 | method, 94 | f"{self.api_url}{cmd}", 95 | headers=self.headers, 96 | ) 97 | ) 98 | prepped = s.prepare_request(_request) 99 | resp = ( 100 | s.send(prepped, cert=self.cert) 101 | if self.cert 102 | else s.send(prepped, verify=False) 103 | ) 104 | return resp 105 | 106 | ### For Active files request! ### 107 | def _get_active_file_content(self) -> str: 108 | """Returns the content of the currently active (open) file in Obsidian 109 | in markdown format. 110 | 111 | """ 112 | try: 113 | resp = self._send_request("GET", cmd="/active/") 114 | resp.raise_for_status() 115 | if resp.status_code == 200: 116 | logger.info("Success!") 117 | return resp.text # in text/markdown format 118 | except HTTPError as err: 119 | logging.error(err) 120 | return None 121 | 122 | def _append_content_to_active_file(self, content: str): 123 | """Appends content to the end of the currently-open note. 124 | 125 | Args: 126 | content (str): the content to append 127 | """ 128 | self.headers["accept"] = "*/*" 129 | try: 130 | resp = self._send_request("POST", cmd="/active/", data=content) 131 | resp.raise_for_status() 132 | if resp.status_code == 200: 133 | logger.info("Added content successfully!") 134 | except HTTPError as err: 135 | logging.error(err) 136 | return None 137 | 138 | def _update_content_of_active_file(self, content: str): 139 | """Update content of the currently-open note. 140 | 141 | Args: 142 | content (str): the content to update (replace) for the current active note 143 | """ 144 | try: 145 | resp = self._send_request("POST", cmd="/active/", data=content) 146 | resp.raise_for_status() 147 | if resp.status_code == 200: 148 | logger.info("Updated content successfully!") 149 | except HTTPError as err: 150 | logging.error(err) 151 | return None 152 | 153 | def _delete_active_file(self): 154 | """Delete the currently active file in Obsidian""" 155 | try: 156 | resp = self._send_request("DELETE", cmd="/active/") 157 | resp.raise_for_status() 158 | if resp.status_code == 204: 159 | logger.info("Deleted the currently active file in Obsidian.") 160 | except HTTPError as err: 161 | logging.error(err) 162 | return None 163 | 164 | def _insert_content_of_active_file( 165 | self, 166 | content: str, 167 | heading: str, 168 | insert_position: str, 169 | heading_boundary: str = "", 170 | ): 171 | """Insert content into the currently-open note 172 | relative to a heading within that note. 173 | 174 | This is useful if you have a document having multiple headings, 175 | and you would like to insert content below one of those headings. 176 | 177 | By default, this will find the first heading matching the name you specify. 178 | 179 | Args: 180 | content: the content to insert. 181 | 182 | heading: name of heading relative to which you would like your content inserted. 183 | May be a sequence of nested headers delimited by "::". 184 | 185 | insert_position: position at which you would like your content inserted; 186 | Valid options are "end" or "beginning". 187 | 188 | heading_boundary: set the nested header delimiter to a different value. 189 | This is useful if "::" exists in one of the headers you are attempting to use. 190 | 191 | """ 192 | # set the header parameters 193 | self.headers["accept"] = "*/*" 194 | self.headers["Heading"] = heading 195 | self.headers["Content-Insertion-Position"] = insert_position 196 | self.headers["Content-Type"] = "text/markdown" 197 | if heading_boundary != "": 198 | self.headers["Heading-Boundary"] = heading_boundary 199 | 200 | try: 201 | resp = self._send_request("PATCH", cmd="/active/", data=content) 202 | resp.raise_for_status() 203 | if resp.status_code == 200: 204 | logger.info("Inserted the content successfully!") 205 | except HTTPError as err: 206 | logging.error(err) 207 | return None 208 | 209 | ### Target files in your vault ### 210 | def _get_target_file_content( 211 | self, 212 | target_filename: str, 213 | return_format: str = "text/markdown", # ] 214 | ) -> Dict[str, Any]: 215 | """ 216 | Return the content of the file at the specified path 217 | in your vault should the file exist. 218 | 219 | Args: 220 | target_filename (str): path to the file to return (relative to your vault root). 221 | return_format (str): Returned format of the content. 222 | Default is 'text/markdown, 223 | can be set to 'json' to get frontmatter, tags, and stats. 224 | """ 225 | if return_format == "json": 226 | self.headers["accept"] = "application/vnd.olrapi.note+json" 227 | 228 | try: 229 | resp = self._send_request("POST", cmd=f"/vault/{target_filename}") 230 | resp.raise_for_status() 231 | if resp.status_code == 200: 232 | logger.info(f"Got the content of {target_filename} successfully!") 233 | return resp.text if return_format == "text/markdown" else resp.json() 234 | except HTTPError as err: 235 | logging.error(err) 236 | return None 237 | 238 | return resp.json() if return_format == "json" else resp 239 | 240 | def _create_or_update_file(self, target_filename: str, content: str): 241 | """Create a new file in your vault or 242 | update the content of an existing one if the specified file already exists. 243 | 244 | Args: 245 | target_filename (str): path to the file to return (relative to your vault root). 246 | content (str): the content to insert. 247 | """ 248 | self.headers["accept"] = "*/*" 249 | try: 250 | resp = self._send_request( 251 | "PUT", cmd=f"/vault/{target_filename}", data=content 252 | ) 253 | resp.raise_for_status() 254 | if resp.status_code == 200: 255 | logger.info(f"Updated {target_filename} successfully!") 256 | except HTTPError as err: 257 | logging.error(err) 258 | return None 259 | 260 | def _delete_target_file(self, target_filename: str): 261 | """Delete a target file in your Obsidian vault. 262 | 263 | Args: 264 | target_filename (str): path to the file to return (relative to your vault root). 265 | 266 | """ 267 | try: 268 | resp = self._send_request("DELETE", cmd="/active/") 269 | resp.raise_for_status() 270 | if resp.status_code == 204: 271 | logger.info(f"Deleted {target_filename} in Obsidian.") 272 | except HTTPError as err: 273 | logging.error(err) 274 | return None 275 | 276 | def _append_content_to_target_file(self, target_filename: str, content: str): 277 | """ 278 | Appends content to the end of the target note. 279 | If the specified file does not yet exist, it will be created as an empty file. 280 | """ 281 | self.headers["accept"] = "*/*" 282 | try: 283 | resp = self._send_request( 284 | "PUT", cmd=f"/vault/{target_filename}", data=content 285 | ) 286 | resp.raise_for_status() 287 | if resp.status_code == 200: 288 | logger.info(f"Updated {target_filename} successfully!") 289 | except HTTPError as err: 290 | logging.error(err) 291 | return None 292 | 293 | def _insert_content_of_target_file( 294 | self, 295 | target_filename: str, 296 | content: str, 297 | heading: str, 298 | insert_position: str, 299 | heading_boundary: str = "", 300 | ): 301 | """Inserts content into a target note 302 | relative to a heading within that note. 303 | 304 | This is useful if you have a document having multiple headings, 305 | and you would like to insert content below one of those headings. 306 | 307 | By default, this will find the first heading matching the name you specify. 308 | 309 | Args: 310 | target_filename (str): The filename of the target note to insert to. 311 | 312 | content (str): the content to insert. 313 | 314 | heading: name of heading relative to which you would like your content inserted. 315 | May be a sequence of nested headers delimited by "::". 316 | 317 | insert_position: position at which you would like your content inserted; 318 | Valid options are "end" or "beginning". 319 | 320 | heading_boundary: set the nested header delimiter to a different value. 321 | This is useful if "::" exists in one of the headers you are attempting to use. 322 | 323 | """ 324 | # set the header parameters 325 | self.headers["accept"] = "*/*" 326 | self.headers["Heading"] = heading 327 | self.headers["Content-Insertion-Position"] = insert_position 328 | self.headers["Content-Type"] = "text/markdown" 329 | if heading_boundary != "": 330 | self.headers["Heading-Boundary"] = heading_boundary 331 | 332 | try: 333 | resp = self._send_request( 334 | "PATCH", cmd=f"/vault/{target_filename}", data=content 335 | ) 336 | resp.raise_for_status() 337 | if resp.status_code == 200: 338 | logger.info(f"Inserted content to {target_filename} successfully!") 339 | except HTTPError as err: 340 | logging.error(err) 341 | return None 342 | 343 | def _delete_target_file(self, target_filename: str): 344 | """Delete target file from the vault. 345 | 346 | Args: 347 | target_filename (str): The target file to delete from the vault. 348 | 349 | """ 350 | self.headers["accept"] = "*/*" 351 | try: 352 | resp = self._send_request("DELETE", cmd=f"/vault/{target_filename}") 353 | resp.raise_for_status() 354 | if resp.status_code == 200: 355 | logger.info(f"Deleted {target_filename} successfully!") 356 | except HTTPError as err: 357 | logging.error(err) 358 | return None 359 | 360 | ### Value Directoryies ### 361 | 362 | def _list_files_in_vault(self, target_dir: str) -> Dict[str, any]: 363 | """Lists files in the target directory of your vault. 364 | 365 | Args: 366 | target_dir (str): Path to list files from (relative to your vault root). 367 | Note that empty directories will not be returned. 368 | 369 | 370 | Returns: 371 | Dict[str, any]: All the files in the target directory in JSON format. 372 | 373 | """ 374 | try: 375 | self.headers["accept"] = "application/json" 376 | resp = self._send_request( 377 | "GET", 378 | cmd=f"/vault/{target_dir}", 379 | ) 380 | resp.raise_for_status() 381 | if resp.status_code == 200: 382 | logger.info(f"Got the list of files in {target_dir} successfully!") 383 | return resp.json() 384 | except HTTPError as err: 385 | logging.error(err) 386 | return None 387 | 388 | ### Commands ### 389 | def _list_commands(self) -> Dict[str, any]: 390 | """Lists all available commands in Obsidian. 391 | 392 | Returns: 393 | Dict[str, any]: All the available commands in Obsidian in JSON format. 394 | 395 | """ 396 | try: 397 | self.headers["accept"] = "application/json" 398 | resp = self._send_request( 399 | "GET", 400 | cmd="/commands/", 401 | ) 402 | resp.raise_for_status() 403 | if resp.status_code == 200: 404 | logger.info("Fetched all the commands successfully!") 405 | return resp.json() 406 | except HTTPError as err: 407 | logging.error(err) 408 | return None 409 | 410 | def _run_command(self, command_id: str) -> Dict[str, any]: 411 | """Lists all available commands in Obsidian. 412 | 413 | Args: 414 | command_id (str): The ID of the command to execute. 415 | Can be retrieved using `_list_commands` function 416 | 417 | Returns: 418 | Dict[str, any]: All the available commands in Obsidian in JSON format. 419 | 420 | """ 421 | try: 422 | self.headers["accept"] = "*/*" 423 | resp = self._send_request( 424 | "POST", 425 | cmd=f"/commands/{command_id}", 426 | ) 427 | resp.raise_for_status() 428 | if resp.status_code == 200: 429 | logger.info("The command is executed sucessfully!") 430 | except HTTPError as err: 431 | logging.error(err) 432 | return None 433 | 434 | ### Search ### 435 | def _search_with_query(self, request_body: str or dict) -> List[dict[str, any]]: 436 | """Search for documents matching a specificed search query. 437 | Evaluates a provided query against each file in your vault. 438 | This endpoint supports multiple query formats, including: 439 | - Dataview DQL 440 | - Json 441 | Your query should be specified in your request's body, 442 | and will be interpreted according to the Content-type header 443 | you specify from the below options. 444 | 445 | 446 | Dataview DQL (application/vnd.olrapi.dataview.dql+txt) 447 | Accepts a TABLE-type Dataview query as a text string. 448 | See Dataview's query documentation for information on how to construct a query. 449 | 450 | JsonLogic (application/vnd.olrapi.jsonlogic+json) 451 | Accepts a JsonLogic query specified as JSON. 452 | See JsonLogic's documentation for information about the base set of operators available, 453 | but in addition to those operators the following operators are available: 454 | 455 | glob: [PATTERN, VALUE]: Returns true if a string matches a glob pattern. 456 | E.g.: {"glob": ["*.foo", "bar.foo"]} is true and {"glob": ["*.bar", "bar.foo"]} is false. 457 | regexp: [PATTERN, VALUE]: Returns true if a string matches a regular expression. 458 | E.g.: {"regexp": [".*\.foo", "bar.foo"] is true and {"regexp": [".*\.bar", "bar.foo"]} is false. 459 | Returns only non-falsy results. "Non-falsy" here treats the following values as "falsy": 460 | 461 | - false 462 | - null or undefined 463 | - 0 464 | -[] 465 | - {} 466 | 467 | Files are represented as an object having the schema described in the Schema 468 | named 'NoteJson' at the bottom of this page. 469 | Understanding the shape of a JSON object from a schema can be tricky; 470 | so you may find it helpful to examine the generated metadata 471 | for individual files in your vault to understand exactly what values are returned. 472 | To see that, access the GET /vault/{filePath} route setting the header: 473 | Accept: application/vnd.olrapi.note+json. 474 | See examples below for working examples of queries 475 | performing common search operations. 476 | 477 | Args: 478 | request_body (str or dict): The query to use to search for file in your fault. 479 | Can be retrieved using `_list_commands` function 480 | 481 | Returns: 482 | List[dict[str, any]]: All the files that match the query. 483 | 484 | """ 485 | try: 486 | self.headers["accept"] = "application/json" 487 | self.headers["Content-Type"] = ( 488 | "application/vnd.olrapi.dataview.dql+txt" 489 | if isinstance(request_body, str) 490 | else "application/vnd.olrapi.jsonlogic+json" 491 | ) 492 | 493 | req_body = ( 494 | request_body 495 | if isinstance(request_body, str) 496 | else json.dumps(request_body) 497 | ) 498 | 499 | resp = self._send_request("POST", cmd="/search/", data=req_body) 500 | resp.raise_for_status() 501 | if resp.status_code == 200: 502 | logger.info("Got the results!") 503 | return resp.json() 504 | except HTTPError as err: 505 | logging.error(err) 506 | return None 507 | 508 | def _search_with_simple_query( 509 | self, query: str, content_length: int = 100 510 | ) -> List[dict[str, any]]: 511 | """Search for documents matching a specificed search text query. 512 | 513 | Args: 514 | query (str): The search query to use to search for file in your fault. 515 | content_length (int): How much context to return around the matching string. 516 | Default: 100 517 | 518 | Returns: 519 | List[dict[str, any]]: Files that match the query with context. 520 | 521 | """ 522 | try: 523 | self.headers["accept"] = "application/json" 524 | 525 | resp = self._send_request( 526 | "POST", 527 | cmd=f"/search/{query}&contextLength={content_length}", 528 | ) 529 | resp.raise_for_status() 530 | if resp.status_code == 200: 531 | logger.info("Got the results!") 532 | return resp.json() 533 | except HTTPError as err: 534 | logging.error(err) 535 | return None 536 | 537 | def _search_with_gui( 538 | self, query: str, content_length: int = 100 539 | ) -> List[dict[str, any]]: 540 | """Uses the search functionality built into the Obsidian UI to find matching files. 541 | 542 | Note: 543 | This particular method relies on interacting with the UI directly rather than 544 | interacting with your notes behind-the-scenes; 545 | so you will see the search panel open when sending requests to this API. 546 | As far as the developers of this library are aware, this is unavoidable. 547 | 548 | Args: 549 | query (str): The search query to use to search for file in your fault. 550 | Search options include: 551 | - path: match the path of a file 552 | - file: match file name 553 | - tag: search for tags 554 | - line:() search for keywords on the same line 555 | - section: search for keywords under the same heading 556 | See the search field in the Obsidian UI for a better understanding of 557 | what options are available. 558 | 559 | content_length (int): How much context to return around the matching string. 560 | Default: 100 561 | 562 | Returns: 563 | List[dict[str, any]]: Files that match the query with context. 564 | 565 | """ 566 | try: 567 | self.headers["accept"] = "application/json" 568 | 569 | resp = self._send_request( 570 | "POST", 571 | cmd=f"/search/gui/?{query}&contextLength={content_length}", 572 | ) 573 | resp.raise_for_status() 574 | if resp.status_code == 200: 575 | logger.info("Got the results!") 576 | return resp.json() 577 | except HTTPError as err: 578 | logging.error(err) 579 | return None 580 | 581 | ### Open ### 582 | def _open_file( 583 | self, 584 | target_filename: str, 585 | new_leaf: bool = False, 586 | ) -> List[dict[str, any]]: 587 | """Opens the specified document in Obsidian. 588 | 589 | Note: 590 | Obsidian will create a new document at the path you have specified 591 | if such a document did not already exist. 592 | 593 | Args: 594 | target_filename (str): Path to the file to return (relative to your vault root). 595 | new_leaf (bool): Whether to open this note as a new leaf. Defaults to False. 596 | 597 | Returns: 598 | List[dict[str, any]]: Files that match the query with context. 599 | 600 | """ 601 | try: 602 | self.headers["accept"] = "application/json" 603 | 604 | resp = self._send_request( 605 | "POST", 606 | cmd=f"/open/{target_filename}?newLeaf={new_leaf}", 607 | ) 608 | resp.raise_for_status() 609 | if resp.status_code == 200: 610 | logger.info(f"Opened {target_filename} in Obsidian.") 611 | except HTTPError as err: 612 | logging.error(err) 613 | return None 614 | --------------------------------------------------------------------------------