├── .drone.yml ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── logs └── .gitkeep ├── pyproject.toml ├── pytrilium ├── PyTrilium.py ├── PyTriliumAttributeClient.py ├── PyTriliumBranchClient.py ├── PyTriliumCalendarClient.py ├── PyTriliumClient.py ├── PyTriliumCustomClient.py ├── PyTriliumNoteClient.py ├── __init__.py └── log.py └── requirements.txt /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: Format Python code, etc. 3 | 4 | trigger: 5 | branch: 6 | - master 7 | 8 | steps: 9 | - name: Checkout dev branch instead of default branch 10 | image: python 11 | commands: 12 | - git checkout master 13 | 14 | - name: Print the git status 15 | image: python 16 | commands: 17 | - git status 18 | - echo "Current commit is $(git log --pretty=format:'%h' -n 1)" 19 | 20 | - name: Format code if required 21 | image: python 22 | commands: 23 | - python -m pip install black 24 | - black . 25 | - git diff --quiet && git diff --staged --quiet || git commit -am '[DRONE] [CI SKIP] Formatted code' 26 | - git push --set-upstream origin master 27 | 28 | --- 29 | kind: pipeline 30 | name: Update main bastion tools when pushing to release 31 | 32 | trigger: 33 | branch: 34 | - dev 35 | 36 | steps: 37 | - name: Checkout dev branch instead of default branch 38 | image: python 39 | commands: 40 | - git checkout dev 41 | 42 | - name: Print the git status 43 | image: python 44 | commands: 45 | - git status 46 | - echo "Current commit is $(git log --pretty=format:'%h' -n 1)" 47 | 48 | - name: Format code if required 49 | image: python 50 | commands: 51 | - python -m pip install black 52 | - black . 53 | - git diff --quiet && git diff --staged --quiet || git commit -am '[DRONE] [CI SKIP] Formatted code' 54 | - git push --set-upstream origin master 55 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Format code, create binaries, publish to Pypi 2 | 3 | # Controls when the workflow will run 4 | 5 | on: 6 | push: 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | 10 | jobs: 11 | # This workflow contains a single job called "build" 12 | build: 13 | # The type of runner that the job will run on 14 | runs-on: ubuntu-latest 15 | 16 | # Steps represent a sequence of tasks that will be executed as part of the job 17 | steps: 18 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Add release version to environment. 26 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 27 | 28 | - name: Create Pypi artifacts 29 | run: | 30 | python3 -m pip install --upgrade build 31 | python3 -m build 32 | 33 | - name: Publish a Python distribution to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | 38 | - uses: actions/checkout@v3 39 | - name: Create a Release 40 | uses: elgohr/Github-Release-Action@v5 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 43 | with: 44 | tag_name: ${{ env.RELEASE_VERSION }} 45 | release_name: Release ${{ env.RELEASE_VERSION }} 46 | draft: false 47 | prerelease: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> JupyterNotebooks 2 | # gitignore template for Jupyter Notebooks 3 | # website: http://jupyter.org/ 4 | 5 | .ipynb_checkpoints 6 | */.ipynb_checkpoints/* 7 | 8 | # IPython 9 | profile_default/ 10 | ipython_config.py 11 | 12 | # Remove previous ipynb_checkpoints 13 | # git rm -r .ipynb_checkpoints/ 14 | 15 | # ---> Python 16 | # Byte-compiled / optimized / DLL files 17 | __pycache__/ 18 | *.py[cod] 19 | *$py.class 20 | 21 | # C extensions 22 | *.so 23 | 24 | # Distribution / packaging 25 | .Python 26 | build/ 27 | develop-eggs/ 28 | dist/ 29 | downloads/ 30 | eggs/ 31 | .eggs/ 32 | lib/ 33 | lib64/ 34 | parts/ 35 | sdist/ 36 | var/ 37 | wheels/ 38 | share/python-wheels/ 39 | *.egg-info/ 40 | .installed.cfg 41 | *.egg 42 | MANIFEST 43 | 44 | # PyInstaller 45 | # Usually these files are written by a python script from a template 46 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 47 | *.manifest 48 | *.spec 49 | 50 | # Installer logs 51 | pip-log.txt 52 | pip-delete-this-directory.txt 53 | 54 | # Unit test / coverage reports 55 | htmlcov/ 56 | .tox/ 57 | .nox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | coverage.xml 63 | *.cover 64 | *.py,cover 65 | .hypothesis/ 66 | .pytest_cache/ 67 | cover/ 68 | 69 | # Translations 70 | *.mo 71 | *.pot 72 | 73 | # Django stuff: 74 | *.log 75 | local_settings.py 76 | db.sqlite3 77 | db.sqlite3-journal 78 | 79 | # Flask stuff: 80 | instance/ 81 | .webassets-cache 82 | 83 | # Scrapy stuff: 84 | .scrapy 85 | 86 | # Sphinx documentation 87 | docs/_build/ 88 | 89 | # PyBuilder 90 | .pybuilder/ 91 | target/ 92 | 93 | # Jupyter Notebook 94 | .ipynb_checkpoints 95 | 96 | # IPython 97 | profile_default/ 98 | ipython_config.py 99 | 100 | # pyenv 101 | # For a library or package, you might want to ignore these files since the code is 102 | # intended to run in multiple environments; otherwise, check them in: 103 | # .python-version 104 | 105 | # pipenv 106 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 107 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 108 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 109 | # install all needed dependencies. 110 | #Pipfile.lock 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # Logging script output 156 | scriptoutput.log* 157 | 158 | custom_testing.py 159 | etapi.openapi.yaml 160 | test.zip 161 | requirements.txt 162 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pytrilium 2 | 3 | Python SDK (wrapper, whatever you want to call it) for interacting with [Trilium's](https://github.com/zadam/trilium) ETAPI. The exact OpenAPI spec definition file that I'm trying to match can be found [here](https://github.com/zadam/trilium/blob/master/src/etapi/etapi.openapi.yaml). 4 | 5 | You can use either your password or an ETAPI token to authenticate to the Trilium instance. 6 | 7 | 8 | 9 | ## 🖥 Installation 10 | 11 | ```bash 12 | pip install pytrilium 13 | ``` 14 | 15 | ## Examples 16 | 17 | ### 🔐 Authenticating (via ETAPI token or password) 18 | Token: 19 | ```python 20 | from pytrilium.PyTrilium import PyTrilium 21 | 22 | pytrilium_client = PyTrilium("https://trilium.example.com", token="TTDaTeG3sadffy2_eOtgqvZoI6xHvga/6vhz61ezke1RpoX47vPI93zs5qs=") 23 | 24 | print(pytrilium_client.get_note_content_by_id("MLDQ3EGWsU8e")) 25 | ``` 26 | 27 | Password: 28 | ```python 29 | from pytrilium.PyTrilium import PyTrilium 30 | 31 | pytrilium_client = PyTrilium("https://trilium.example.com", password="thisisabadpassword1") 32 | 33 | print(pytrilium_client.get_note_content_by_id("MLDQ3EGWsU8e")) 34 | ``` 35 | 36 | ### 📒 Basic Use Case 37 | 38 | This will just print out the contents of a note, as one large string. Trilium's API returns it in the HTML format. 39 | 40 | ```python 41 | from pytrilium.PyTrilium import PyTrilium 42 | 43 | pytrilium_client = PyTrilium("https://trilium.example.com", token="TTDaTeG3sadffy2_eOtgqvZoI6xHvga/6vhz61ezke1RpoX47vPI93zs5qs=") 44 | 45 | print(pytrilium_client.get_note_content_by_id("MLDQ3EGWsU8e")) 46 | ``` 47 | 48 | Export a note to a file 49 | 50 | ```python 51 | from pytrilium.PyTrilium import PyTrilium 52 | 53 | test_client = PyTrilium("https://trilium.example.com", token="TTDaTeG3sadffy2_eOtgqvZoI6xHvga/6vhz61ezke1RpoX47vPI93zs5qs=") 54 | 55 | print(test_client.get_note_content_by_id("MLDQ3EGWsU8e")) 56 | 57 | test_client.export_note_by_id("MLDQ3EGWsU8e", "./test.zip") 58 | ``` 59 | 60 | ### 🧠 More Advanced 61 | 62 | If I'm braindead or this just doesn't do what you want it to, you can still use the underlying `requests.Session` that I've set up so that you can still interact with the API. This way you can still make manual requests if you would like to, and do whatever you would like with them. 63 | 64 | To print out a Note's content without using other helpers - 65 | 66 | ```python 67 | from pytrilium.PyTrilium import PyTrilium 68 | 69 | pytrilium_client = PyTrilium("https://trilium.example.com", token="TTDaTeG3sadffy2_eOtgqvZoI6xHvga/6vhz61ezke1RpoX47vPI93zs5qs=") 70 | 71 | resp = pytrilium_client.make_request('notes//content') 72 | print(resp.text) 73 | ``` 74 | 75 | ## Currently implemented functions 76 | ``` 77 | attempt_basic_call 78 | auth_login 79 | auth_logout 80 | clean_url 81 | create_note 82 | create_note_revision 83 | delete_branch_by_id 84 | delete_note_by_id 85 | export_note_by_id 86 | get_attribute_by_id 87 | get_branch_by_id 88 | get_days_note 89 | get_inbox_note 90 | get_months_note 91 | get_note_by_id 92 | get_note_content_by_id 93 | get_weeks_note 94 | get_year_note 95 | make_request 96 | make_requests_session 97 | patch_branch_by_id 98 | patch_note_by_id 99 | post_attribute 100 | post_branch 101 | print_custom_functions 102 | put_note_content_by_id 103 | refresh_note_ordering 104 | set_session_auth 105 | valid_response_codes 106 | ``` 107 | 108 | ## Misc 109 | To get a quick list of currently available paths from the OpenAPI spec (doesn't always mean what's in this package or not): 110 | 111 | ```bash 112 | curl https://raw.githubusercontent.com/zadam/trilium/master/src/etapi/etapi.openapi.yaml 2>/dev/null | yq -e ".paths | keys" 113 | ``` 114 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/perfectra1n/pytrilium/fdb2826115baab4d8c0aff9a3f85e7d800a5bf00/logs/.gitkeep -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "PyTrilium" 7 | version = "1.2.4" 8 | authors = [ 9 | { name="perfectra1n", email="perf3ctsec@gmail.com" }, 10 | ] 11 | description = "A Python wrapper for the Trilium Notes API" 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "requests", 21 | 'coloredlogs' 22 | ] 23 | packages = [ 24 | {include = "pytrilium"}, 25 | ] 26 | 27 | [project.urls] 28 | "Homepage" = "https://github.com/perfectra1n/pytrilium" 29 | "Bug Tracker" = "https://github.com/perfectra1n/pytrilium/issues" 30 | 31 | [tool.black] 32 | line-length = 120 33 | target_version = ['py37', 'py38', 'py39'] 34 | exclude = ''' 35 | ( 36 | /( 37 | | \.git 38 | | \.venv 39 | | \.mypy_cache 40 | )/ 41 | ) 42 | ''' 43 | 44 | [tool.isort] 45 | line_length = 120 46 | profile = "black" 47 | -------------------------------------------------------------------------------- /pytrilium/PyTrilium.py: -------------------------------------------------------------------------------- 1 | from .PyTriliumCustomClient import PyTriliumCustomClient 2 | 3 | from datetime import datetime 4 | 5 | class PyTrilium(PyTriliumCustomClient): 6 | def __init__(self, url, token=None, password=None, debug=False) -> None: 7 | """Initializes the PyTrilium class. You need to either provide an ETAPI token OR a password (which will then be used to generate an ETAPI token). 8 | 9 | Parameters 10 | ---------- 11 | url : str 12 | The URL of the Trilium instance. This should include the protocol (http:// or https://) and the port if it is not the protocol's respective port (443 for https, 80 for http). e.g. `https://trilium.example.com:8080` 13 | token : str, optional 14 | The token for the Trilium instance. This can be found in the Trilium settings. 15 | debug : bool, optional 16 | If you would like to enable debugging, set this to True, by default False 17 | password : str, optional 18 | The password for the Trilium instance. This can be found in the Trilium settings. This is only required if you are using Trilium's built-in authentication, by default None 19 | """ 20 | super().__init__(url, token, debug) 21 | 22 | # Set up the requests session, the validate that either a password or a token was provided 23 | # If not, return an error 24 | self.make_requests_session() 25 | if not token and not password: 26 | raise ValueError("You must provide either a token or a password.") 27 | if password: 28 | self.token = self.auth_login(password) 29 | if token: 30 | self.token = token 31 | self.set_session_auth(self.token) 32 | 33 | # Attempt a basic call to make sure that the token is valid 34 | self.attempt_basic_call() 35 | 36 | def auth_login(self, password:str) -> str: 37 | """Authenticate to Trilium using a password. This should not be called manually. This will return the token that can be used to authenticate to Trilium in future requests. 38 | 39 | Parameters 40 | ---------- 41 | password : str 42 | The password to send to Trilium 43 | 44 | Returns 45 | ------- 46 | str 47 | The ETAPI token that can be used to authenticate to Trilium in future requests. 48 | """ 49 | 50 | # Returns {"authToken": "33xHpBRHetAN_fh3g0B7MQaaaaj1871fbXLbbK4JAT06GGmZOSwet56M="} 51 | data = {"password": password} 52 | 53 | resp = self.make_request("/auth/login", data=data, method="POST") 54 | return resp.json()["authToken"] 55 | 56 | def create_backup(self, backup_name:str = datetime.today().strftime("%m_%d_%Y")) -> bool: 57 | """Create a backup that is placed on Trilium's server. This should not be called manually. 58 | 59 | Parameters 60 | ---------- 61 | backup_name : str, optional 62 | The name you wish to have appended to the backup, starts out with `backup-.db`, by default datetime.today().strftime("%m_%d_%Y") 63 | 64 | Returns 65 | ------ 66 | bool 67 | Returns if the backup was successful or not. 68 | """ 69 | request = self.make_request(f"/backup/{backup_name}", method="PUT") 70 | if request.status_code == 204: 71 | return True 72 | else: 73 | return False 74 | 75 | 76 | def auth_logout(self): 77 | """Logs out of Trilium. This should not be called manually.""" 78 | self.make_request("/auth/logout", method="POST") 79 | 80 | def get_inbox_note(self, date:str): 81 | """Get the inbox's note for a date. 82 | 83 | Parameters 84 | ---------- 85 | date : str 86 | The date which you would like to fetch the inbox note for. This should be in the format of YYYY-MM-DD. e.g. `2021-01-01` 87 | """ 88 | self.make_request(f"/inbox/{date}") 89 | 90 | def print_custom_functions(self): 91 | 92 | dont_show_these_funcs = ["url", "token", "session"] 93 | 94 | list_of_funcs = dir(self) 95 | for func in list_of_funcs: 96 | if "__" not in func and "logger" not in func: 97 | if func not in dont_show_these_funcs: 98 | print(func) -------------------------------------------------------------------------------- /pytrilium/PyTriliumAttributeClient.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from .PyTriliumClient import PyTriliumClient 3 | 4 | 5 | class PyTriliumAttributeClient(PyTriliumClient): 6 | def __init__(self, url, token, debug=False) -> None: 7 | super().__init__(url, token, debug) 8 | 9 | def get_attribute_by_id(self, attribute_id: str) -> dict: 10 | """Given the Attribute's ID, this will return the Attribute's information. 11 | 12 | Parameters 13 | ---------- 14 | attribute_id : str 15 | Trilium's ID for the Attribute, this can be seen by clicking the 'i' on the attribute, near the top. 16 | 17 | Returns 18 | ------- 19 | dict 20 | The response from the Trilium API. 21 | """ 22 | return self.make_request(f"/attributes/{attribute_id}").json() 23 | 24 | def post_attribute(self, data: str) -> dict: 25 | """This will create a new Attribute. 26 | 27 | Parameters 28 | ---------- 29 | data : str 30 | The data to send to the Trilium API.This should be in the format of a string. 31 | 32 | Returns 33 | ------- 34 | dict 35 | The JSON response from Trilium, as a dictionary. 36 | """ 37 | return self.make_request("/attributes", method="POST", data=data).json() 38 | -------------------------------------------------------------------------------- /pytrilium/PyTriliumBranchClient.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from .PyTriliumClient import PyTriliumClient 3 | 4 | 5 | class PyTriliumBranchClient(PyTriliumClient): 6 | def __init__(self, url, token, debug=False) -> None: 7 | super().__init__(url, token, debug) 8 | 9 | def get_branch_by_id(self, branch_id: str) -> dict: 10 | """Given the Branch's ID, this will return the Branch's information. 11 | 12 | Parameters 13 | ---------- 14 | branch_id : str 15 | Trilium's ID for the Branch, this can be seen by clicking the 'i' on the branch, near the top. 16 | 17 | Returns 18 | ------- 19 | dict 20 | The response from the Trilium API. 21 | """ 22 | return self.make_request(f"/branches/{branch_id}").json() 23 | 24 | def post_branch(self, data: str) -> dict: 25 | """This will create a new Branch. 26 | 27 | Parameters 28 | ---------- 29 | data : str 30 | The data to send to the Trilium API.This should be in the format of a string. 31 | 32 | Returns 33 | ------- 34 | dict 35 | The response from the Trilium API. 36 | """ 37 | return self.make_request("/branches", method="POST", data=data) 38 | 39 | def patch_branch_by_id(self, branch_id: str, data: str) -> dict: 40 | """Given the Branch's ID, this will update the Branch's information. 41 | 42 | Parameters 43 | ---------- 44 | branch_id : str 45 | Trilium's ID for the Branch, this can be seen by clicking the 'i' on the branch, near the top. 46 | data : str 47 | The data to send to the Trilium API.This should be in the format of a string. 48 | 49 | Returns 50 | ------- 51 | dict 52 | The JSON response from Trilium, as a dictionary. 53 | """ 54 | return self.make_request(f"/branches/{branch_id}", method="PATCH", data=data) 55 | 56 | def delete_branch_by_id(self, branch_id: str) -> dict: 57 | """Given the Branch's ID, this will delete the Branch. 58 | 59 | Parameters 60 | ---------- 61 | branch_id : str 62 | Trilium's ID for the Branch, this can be seen by clicking the 'i' on the branch, near the top. 63 | 64 | Returns 65 | ------- 66 | dict 67 | The JSON response from Trilium, as a dictionary. 68 | """ 69 | return self.make_request(f"/branches/{branch_id}", method="DELETE") 70 | -------------------------------------------------------------------------------- /pytrilium/PyTriliumCalendarClient.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from .PyTriliumClient import PyTriliumClient 3 | 4 | 5 | class PyTriliumCalendarClient(PyTriliumClient): 6 | def __init__(self, url, token, debug=False) -> None: 7 | super().__init__(url, token, debug) 8 | 9 | def get_year_note(self, year: str) -> dict: 10 | """Get the note for a year, in Trilium's calendar. 11 | 12 | Parameters 13 | ---------- 14 | year : str 15 | The year you want to fetch from Trilium's calendar. 16 | 17 | Returns 18 | ------- 19 | dict 20 | The JSON response from Trilium, as a dictionary. 21 | """ 22 | return self.make_request(f"calendar/years/{year}").json() 23 | 24 | def get_weeks_note(self, weeks: str) -> dict: 25 | """Get the note for a week, in Trilium's calendar. 26 | 27 | Parameters 28 | ---------- 29 | weeks : str 30 | The week you want to fetch from Trilium's calendar. 31 | 32 | Returns 33 | ------- 34 | dict 35 | The JSON response from Trilium, as a dictionary. 36 | """ 37 | return self.make_request(f"calendar/weeks/{weeks}").json() 38 | 39 | def get_months_note(self, months: str) -> dict: 40 | """Get the note for a month, in Trilium's calendar. 41 | 42 | Parameters 43 | ---------- 44 | months : str 45 | The month you want to fetch from Trilium's calendar. 46 | 47 | Returns 48 | ------- 49 | dict 50 | The JSON response from Trilium, as a dictionary. 51 | """ 52 | return self.make_request(f"calendar/months/{months}").json() 53 | 54 | def get_days_note(self, date: str) -> dict: 55 | """Get the note for a day, in Trilium's calendar. 56 | 57 | Parameters 58 | ---------- 59 | date : str 60 | The date you want to fetch from Trilium's calendar. 61 | 62 | Returns 63 | ------- 64 | dict 65 | The JSON response from Trilium, as a dictionary. 66 | """ 67 | return self.make_request(f"calendar/days/{date}").json() -------------------------------------------------------------------------------- /pytrilium/PyTriliumClient.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests.adapters import HTTPAdapter, Retry 3 | 4 | # Local import 5 | from . import log 6 | 7 | 8 | class PyTriliumClient: 9 | def __init__(self, url: str, token: str, debug: bool = False) -> None: 10 | """Initializes the PyTriliumClient class. 11 | 12 | Parameters 13 | ---------- 14 | url : str 15 | The URL of the Trilium instance. This should include the protocol (http:// or https://) and the port if it is not the protocol's respective port (443 for https, 80 for http). e.g. `https://trilium.example.com:8080` 16 | token : str 17 | The token for the Trilium instance. This can be found in the Trilium settings. 18 | debug : bool, optional 19 | If you would like to enable debugging, set this to True, by default False 20 | 21 | Raises 22 | ------ 23 | ValueError 24 | If the URL is invalid, this will raise a ValueError. 25 | """ 26 | 27 | self.token = token 28 | if not self.clean_url(url): 29 | raise ValueError( 30 | "Invalid URL, please make sure to include https:// or http:// and that the URL is correct. The attempted URL was: " 31 | + self.url 32 | ) 33 | 34 | self.logger = log.get_logger( 35 | logger_name="PyTriliumClient", 36 | log_file_name="PyTriliumClient.log", 37 | debug=debug, 38 | create_log_file=False, 39 | ) 40 | 41 | # The valid response codes that can come from Triliu 42 | # everything else will be logged as a console warning 43 | self.valid_response_codes = [200, 201, 202, 204] 44 | 45 | def make_requests_session(self) -> None: 46 | """Creates a requests session with the token and user agent header.""" 47 | self.session = requests.Session() 48 | 49 | # Version here 50 | self.session.headers.update({"User-Agent": "pytrilium/1.2.4"}) 51 | #self.session.headers.update({"Content-Type": "application/json"}) 52 | 53 | # Set up retry logic 54 | retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504]) 55 | 56 | # Have it work for both http and https 57 | self.session.mount("https://", HTTPAdapter(max_retries=retries)) 58 | self.session.mount("http://", HTTPAdapter(max_retries=retries)) 59 | 60 | def set_session_auth(self, token: str) -> None: 61 | """Sets the authorization token for the session. 62 | 63 | Parameters 64 | ---------- 65 | token : str 66 | The token to set for the session. 67 | """ 68 | self.session.headers.update({"Authorization": token}) 69 | 70 | def make_request(self, api_endpoint: str, method="GET", data="", params={}) -> requests.Response: 71 | """Standard request method for making requests to the Trilium API. 72 | 73 | Parameters 74 | ---------- 75 | api_endpoint : str 76 | The API endpoint to make the request to. This should not include the URL or the /etapi prefix. 77 | method : str, optional 78 | The HTTP method to use, by default "GET" 79 | data : str, optional 80 | The body data to send with the request, by default "" 81 | params : dict, optional 82 | The parameters to include in the API call, by default {} 83 | 84 | Returns 85 | ------- 86 | requests.Response 87 | The response from the Trilium API. 88 | """ 89 | # We use our own session that holds the token, so we shouldn't 90 | # need to enforce it here. 91 | 92 | request_url = self.url + api_endpoint 93 | req_resp = self.session.request(method, request_url, data=data, params=params) 94 | if req_resp.status_code not in self.valid_response_codes: 95 | self.logger.warning( 96 | f"Possible invalid response code: {str(req_resp.status_code)}, response text: {req_resp.text}" 97 | ) 98 | return req_resp 99 | 100 | def clean_url(self, url: str) -> bool: 101 | """Cleans the URL to make sure it is valid. 102 | 103 | 104 | Parameters 105 | ---------- 106 | url : str 107 | The URL to clean. 108 | 109 | Returns 110 | ------- 111 | bool 112 | If the URL is valid, this will return True. If the URL is invalid, this will return False. 113 | """ 114 | if "/etapi" not in url: 115 | url = url + "/etapi" 116 | if "http" not in url and "https" not in url: 117 | # Then this URL is just scuffed 118 | return False 119 | self.url = url 120 | 121 | return True 122 | 123 | def attempt_basic_call(self) -> None: 124 | """Attempts a basic call to the Trilium API to make sure that the URL and token are valid.""" 125 | resp = self.make_request("/app-info") 126 | if resp.status_code not in self.valid_response_codes: 127 | raise ValueError( 128 | f"Invalid response code: {str(resp.status_code)}, response text: {resp.text}. Response code should be one of {self.valid_response_codes}. Please check your Trilium, URL, and token." 129 | ) 130 | 131 | def get_app_info(self) -> dict: 132 | """Gets the app info from the Trilium API. 133 | 134 | Returns 135 | ------- 136 | dict 137 | The app info from the Trilium API. 138 | """ 139 | return self.make_request("/app-info").json() 140 | 141 | -------------------------------------------------------------------------------- /pytrilium/PyTriliumCustomClient.py: -------------------------------------------------------------------------------- 1 | from .PyTriliumNoteClient import PyTriliumNoteClient 2 | from .PyTriliumBranchClient import PyTriliumBranchClient 3 | from .PyTriliumAttributeClient import PyTriliumAttributeClient 4 | from .PyTriliumCalendarClient import PyTriliumCalendarClient 5 | 6 | # This class inherits from everything else, but also implements custom functions 7 | # so I'm creating this to help save my sanity in the future 8 | class PyTriliumCustomClient(PyTriliumNoteClient, PyTriliumBranchClient, PyTriliumAttributeClient, PyTriliumCalendarClient): 9 | def __init__(self, url, token, debug=False) -> None: 10 | super().__init__(url, token, debug) -------------------------------------------------------------------------------- /pytrilium/PyTriliumNoteClient.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from .PyTriliumClient import PyTriliumClient 3 | 4 | 5 | class PyTriliumNoteClient(PyTriliumClient): 6 | def __init__(self, url, token, debug=False) -> None: 7 | super().__init__(url, token, debug) 8 | 9 | def get_note_by_id(self, note_id: str) -> dict: 10 | """Given the Note's ID, this will return the Note's information. 11 | 12 | Parameters 13 | ---------- 14 | note_id : str 15 | Trilium's ID for the Note, this can be seen by clicking the 'i' on the note, near the top. 16 | 17 | Returns 18 | ------- 19 | dict 20 | The JSON response from Trilium, as a dictionary. 21 | """ 22 | return self.make_request(f"/notes/{note_id}").json() 23 | 24 | def get_note_content_by_id(self, note_id: str) -> str: 25 | """Given the Note's ID, this will return the Note's content. 26 | 27 | Parameters 28 | ---------- 29 | note_id : str 30 | Trilium's ID for the Note, this can be seen by clicking the 'i' on the note, near the top. 31 | 32 | Returns 33 | ------- 34 | str 35 | The content of the note, most likely in HTML format. 36 | """ 37 | return self.make_request(f"/notes/{note_id}/content").text 38 | 39 | def put_note_content_by_id(self, note_id: str, data: str) -> dict: 40 | """Given the Note's ID, this will update the Note's content. 41 | 42 | Parameters 43 | ---------- 44 | note_id : str 45 | Trilium's ID for the Note, this can be seen by clicking the 'i' on the note, near the top. 46 | data : str 47 | The data to send to the Trilium API.This should be in the format of a string. 48 | 49 | Returns 50 | ------- 51 | dict 52 | The JSON response from Trilium, as a dictionary. 53 | """ 54 | return self.make_request(f"/notes/{note_id}/content", method="PUT", data=data).json() 55 | 56 | def patch_note_by_id(self, note_id: str, data: str) -> dict: 57 | """Given the Note's ID, this will update the Note's content. 58 | 59 | Parameters 60 | ---------- 61 | note_id : str 62 | Trilium's ID for the Note, this can be seen by clicking the 'i' on the note, near the top. 63 | data : str 64 | The data to send to the Trilium API.This should be in the format of a string. 65 | 66 | Returns 67 | ------- 68 | dict 69 | The JSON response from Trilium, as a dictionary. 70 | """ 71 | return self.make_request(f"/notes/{note_id}", method="PATCH", data=data).json() 72 | 73 | def delete_note_by_id(self, note_id: str) -> dict: 74 | """Given the Note's ID, this will delete the Note. 75 | 76 | Parameters 77 | ---------- 78 | note_id : str 79 | Trilium's ID for the Note, this can be seen by clicking the 'i' on the note, near the top. 80 | 81 | Returns 82 | ------- 83 | dict 84 | The JSON response from Trilium, as a dictionary. 85 | """ 86 | return self.make_request(f"/notes/{note_id}", method="DELETE").json() 87 | 88 | def export_note_by_id(self, note_id: str, filepath_to_save_export_zip: str, format="html") -> bool: 89 | """Given the Note's ID, export itself and all child notes into a singular .zip archive. 90 | 91 | Parameters 92 | ---------- 93 | note_id : str 94 | Trilium's ID for the Note, this can be seen by clicking the 'i' on the note, near the top. 95 | filepath_to_save_export_zip : str 96 | The path of where to save the .zip archive that is generated by Trilium. 97 | format : str, optional 98 | The format to export the Notes in, by default "html". Can also be "markdown". 99 | 100 | Returns 101 | ------- 102 | bool 103 | Returns True if the Note was exported successfully, and written to the path. Otherwise, returns False. 104 | """ 105 | 106 | # If the filepath ends with a slash, this means that a directory was specified. 107 | # We should add the filename to the end of it. 108 | if filepath_to_save_export_zip.endswith("/"): 109 | filepath_to_save_export_zip += f"pytrilium_export_{note_id}.zip" 110 | 111 | # Make sure the filepath ends with .zip. If not, add it. 112 | if not filepath_to_save_export_zip.endswith(".zip"): 113 | filepath_to_save_export_zip += ".zip" 114 | 115 | try: 116 | params = {"format": format} 117 | response = self.make_request(f"/notes/{note_id}/export", params=params) 118 | 119 | with open(filepath_to_save_export_zip, "wb") as f: 120 | f.write(response.content) 121 | except Exception as e: 122 | print(e) 123 | return False 124 | return True 125 | 126 | def create_note_revision(self, note_id: str, data: str, format: str = "html") -> dict: 127 | """Given the Note's ID, create a new revision of the Note. 128 | 129 | Parameters 130 | ---------- 131 | note_id : str 132 | Trilium's ID for the Note, this can be seen by clicking the 'i' on the note, near the top. 133 | data : str 134 | The data to send to the Trilium API.This should be in the format of a string. 135 | format : str, optional 136 | The format of which the data being provided is in. By default "html". Can also be "markdown". 137 | 138 | Returns 139 | ------- 140 | dict 141 | The JSON response from Trilium, as a dictionary. 142 | """ 143 | 144 | params = {"format": format} 145 | return self.make_request(f"/notes/{note_id}/note-revision", method="POST", data=data, params=params).json() 146 | 147 | def refresh_note_ordering(self, parent_note_id: str) -> dict: 148 | """Given the Note's ID, refresh the node ordering of the Note. 149 | 150 | Parameters 151 | ---------- 152 | parent_note_id : str 153 | Trilium's ID for the parent Note, this can be seen by clicking the 'i' on the parent's note, near the top. 154 | 155 | Returns 156 | ------- 157 | dict 158 | The JSON response from Trilium, as a dictionary. 159 | """ 160 | return self.make_request(f"/refresh-note-ordering/{parent_note_id}", method="POST") 161 | 162 | def create_note(self, data: str) -> dict: 163 | """Create a new Note. 164 | 165 | Parameters 166 | ---------- 167 | data : str 168 | The data to send to the Trilium API.This should be in the format of a string. 169 | 170 | Returns 171 | ------- 172 | dict 173 | The JSON response from Trilium, as a dictionary. 174 | """ 175 | return self.make_request("/create-note", method="POST", data=data).json() 176 | -------------------------------------------------------------------------------- /pytrilium/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/perfectra1n/pytrilium/fdb2826115baab4d8c0aff9a3f85e7d800a5bf00/pytrilium/__init__.py -------------------------------------------------------------------------------- /pytrilium/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import coloredlogs 4 | from logging.handlers import TimedRotatingFileHandler 5 | 6 | FMT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 7 | DATE_FMT = "%m-%d-%Y %H:%M" 8 | 9 | FILE_FORMATTER = logging.Formatter("%(asctime)s - %(funcName)s:%(lineno)d - %(name)s - %(levelname)s - %(message)s") 10 | CONSOLE_FORMATTER = logging.Formatter(FMT) 11 | 12 | # humanfriendly --demo 13 | CUSTOM_FIELD_STYLES = { 14 | "asctime": {"color": "green"}, 15 | "hostname": {"color": "magenta"}, 16 | "levelname": {"bold": True, "color": "black"}, 17 | "name": {"color": 200}, 18 | "programname": {"color": "cyan"}, 19 | "username": {"color": "yellow"}, 20 | } 21 | 22 | 23 | def get_console_handler(debug): 24 | """ 25 | Since we don't want to overwhelm or freak out the user, we're just going to send the output 26 | of debugging over to the file, and only send INFO out to the user. 27 | """ 28 | 29 | # First, let's set the console StreamHandler 30 | console_handler = logging.StreamHandler(sys.stdout) 31 | 32 | # If debug is true, print it out to the screen. 33 | if debug == True: 34 | console_handler.setLevel(logging.DEBUG) 35 | else: 36 | console_handler.setLevel(logging.INFO) 37 | console_handler.setFormatter(CONSOLE_FORMATTER) 38 | return console_handler 39 | 40 | 41 | def get_file_handler(debug=False, log_file_name="log.txt"): 42 | """ 43 | We're going to print out the debug output to the log file. 44 | """ 45 | 46 | file_handler = TimedRotatingFileHandler(log_file_name, when="midnight") 47 | 48 | # We want to print out debug information to this file. 49 | if debug: 50 | file_handler.setLevel(logging.DEBUG) 51 | else: 52 | file_handler.setLevel(logging.INFO) 53 | file_handler.setFormatter(FILE_FORMATTER) 54 | return file_handler 55 | 56 | 57 | def get_logger( 58 | logger_name="Template Repository Logger", 59 | log_file_name="log.txt", 60 | debug=False, 61 | create_log_file=True, 62 | ): 63 | """Get the logger, for the current namespace. 64 | 65 | Args: 66 | logger_name (str, optional): Logger Name. Defaults to "Template Repository Logger". 67 | debug (bool, optional): Debugger boolean. Defaults to False. 68 | 69 | Returns: 70 | logger: return the logger for the current namespace, if it exists. If it does not, create it. 71 | """ 72 | 73 | logger = logging.getLogger(logger_name) 74 | logger.setLevel(logging.DEBUG) 75 | 76 | # If the logger already has the two handlers we've set, no need to add more. 77 | if len(logger.handlers) < 2: 78 | logger.addHandler(get_console_handler(debug)) 79 | if create_log_file != False: 80 | logger.addHandler(get_file_handler(debug, log_file_name=log_file_name)) 81 | # If debugging is not true, we don't want to output DEBUG information to the console. 82 | if debug != False: 83 | coloredlogs.install( 84 | level="DEBUG", 85 | logger=logger, 86 | datefmt=DATE_FMT, 87 | fmt=FMT, 88 | field_styles=CUSTOM_FIELD_STYLES, 89 | ) 90 | logger.debug("Added the debug logger to the console output...") 91 | else: 92 | coloredlogs.install( 93 | level="INFO", 94 | logger=logger, 95 | datefmt=DATE_FMT, 96 | fmt=FMT, 97 | field_styles=CUSTOM_FIELD_STYLES, 98 | ) 99 | 100 | # With this pattern, it's rarely necessary to propagate the error up to parent. 101 | logger.propagate = False 102 | logger.debug("Returning logger to process...") 103 | 104 | return logger 105 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2025.4.26 2 | charset-normalizer==3.4.2 3 | coloredlogs==15.0.1 4 | humanfriendly==10.0 5 | idna==3.10 6 | requests==2.32.3 7 | toml==0.10.2 8 | urllib3==2.4.0 9 | --------------------------------------------------------------------------------