feat: referentiel projets dynamique + validation nommage by design

Durcit la convention de nommage des projets (dérive constatée : 'Sliding
Automation', 'code_versioning'... au lieu des formes canoniques).

- trilium_api.py : projets_canoniques() lit le référentiel = valeurs du label
  projet sur les notes de type=projet (source unique, pas de constante en dur).
  Note-projet CodeVersioning créée (manquait).
- mcp_server.py : _valider_projet() branché dans les 6 tools de création
  (add_decision/history/backlog, new_conversation, create_entite, add_skill).
  Refuse un projet non canonique (suggestion si faute) ou inconnu (renvoi au
  processus de création de projet). Ne verrouille pas si référentiel illisible.
- lint_audit.py : VAL-nommage aligné sur le référentiel (attrape casse, espace
  ET snake_case ; l'ancien 'contient un espace' ratait code_versioning).
- Données : 79 notes ré-étiquetées vers les 3 formes canoniques.

Quality by design : l'erreur de nommage devient impossible à l'écriture, le
Lint n'est plus que le filet de sécurité.
This commit is contained in:
2026-07-17 14:59:33 +02:00
parent d1e67431b6
commit 943acbc573
2425 changed files with 710525 additions and 6 deletions
@@ -0,0 +1,9 @@
"""An unofficial Python wrapper for the ETAPI of trilium
.. moduleauthor:: Nriver
"""
from .version import __version__
def main():
pass
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
import os
def replace_extension(filename: str, new_extension: str) -> str:
"""
Replaces the extension of a given filename with a new extension.
:param filename: Original filename
:param new_extension: New extension to replace the old one (e.g., '.webp')
:return: Filename with the new extension
"""
if not new_extension.startswith('.'):
new_extension = '.' + new_extension
base = os.path.splitext(filename)[0]
return base + new_extension
@@ -0,0 +1,156 @@
import locale
import re
import warnings
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
# Disable MarkupResemblesLocatorWarning globally
warnings.filterwarnings('ignore', category=MarkupResemblesLocatorWarning)
TAG_LEVELS = {'h1': 1, 'h2': 2, 'h3': 3, 'h4': 4, 'h5': 5, 'h6': 6}
def sort_h_tags_with_hierarchy(data, locale_str='zh_CN.UTF-8'):
"""
sort HTML content based on the names of its headings (h1, h2, h3, etc.) in hierarchical order
following the rules of the input language (specified by locale_str)
:param data:
:param locale_str:
:return:
"""
def add_node(parent, node):
if 'children' not in parent:
parent['children'] = []
parent['children'].append(node)
def sort_node_by_name(node, locale_str):
# sort with respect to local language
locale.setlocale(locale.LC_COLLATE, locale_str)
if 'children' in node:
node['children'] = sorted(node['children'], key=lambda x: locale.strxfrm(x['name']))
for child in node['children']:
sort_node_by_name(child, locale_str)
def dfs_concat_data(node):
result = ""
if 'data' in node:
result += node['data']
if 'children' in node:
for child in node['children']:
result += dfs_concat_data(child)
return result
def convert_to_tree(data):
root = {'name': 'root', 'children': []}
current_tags = {1: root}
for item in data:
soup = BeautifulSoup(item, 'html.parser')
tag_name = soup.find(re.compile(r'^h[1-6]$'))
if not tag_name:
continue
tag_level = TAG_LEVELS[tag_name.name]
node = {'name': tag_name.text, 'data': item, 'children': []}
parent_tag_level = tag_level - 1
parent = current_tags.get(parent_tag_level)
if not parent:
# If parent doesn't exist, add the node to the root
add_node(root, node)
else:
add_node(parent, node)
# Update current_tags with the new node
current_tags[tag_level] = node
return root
# Convert data to tree
tree = convert_to_tree(data)
# Sort nodes by name
sort_node_by_name(tree, locale_str)
# Concatenate data in a depth-first search manner
html_string = dfs_concat_data(tree)
return html_string
def add_internal_links(
html_content, keyword_note_id_list, current_note_id=None, exclude_headings=True
):
"""
Adds internal links to the HTML content by replacing keywords with anchor tags.
Args:
html_content (str): The HTML content to process.
keyword_note_id_list (list of tuples): List of (keyword, note_id).
exclude_headings (bool): Whether to exclude heading tags from processing.
current_note_id (str): The ID of the current note to prevent self-referencing.
Returns:
tuple: A tuple containing the updated HTML content and a boolean indicating if replacements were made.
"""
# Use BeautifulSoup to parse the HTML content
soup = BeautifulSoup(html_content, "html.parser")
replaced = False # Flag to check if any replacement happens
# Precompile the keywords and links into a dictionary, excluding self-referencing notes
keyword_to_link = {
keyword: f'<a class="reference-link" href="#root/{note_id}">{keyword}</a>'
for keyword, note_id in keyword_note_id_list
if note_id != current_note_id # Exclude the current note's ID
}
# Create a regex pattern to match any keyword
if not keyword_to_link:
return str(soup), replaced # No keywords to process
keyword_pattern = re.compile(
r'\b(' + '|'.join(re.escape(k) for k in keyword_to_link.keys()) + r')\b'
)
# Tags to exclude from replacement
exclude_tags = ['a']
if exclude_headings:
exclude_tags.extend(['h2', 'h3', 'h4', 'h5', 'h6'])
# Traverse all text nodes once
for text_node in soup.find_all(string=True):
# Skip nodes inside tags that shouldn't contain links
if text_node.parent.name in exclude_tags:
continue
# Replace keywords in the text
def replace_keyword(match):
replaced_keyword = match.group(0)
return keyword_to_link[replaced_keyword]
new_text = keyword_pattern.sub(replace_keyword, text_node)
if new_text != text_node: # If the text has actually changed
text_node.replace_with(BeautifulSoup(new_text, "html.parser"))
replaced = True # Mark that replacement has occurred
return str(soup), replaced
if __name__ == '__main__':
# Example input HTML content
html_content = """
<p> Only root can see this. <a href="#root/python_programming">Python</a> is a widely used programming language. Python has a simple syntax and supports multiple paradigms.</p>
"""
# List of keywords and their corresponding note ids
data = [
["Python", "python_programming"],
["programming language", "programming_language"],
["simple syntax", "simple_syntax"],
["root", "root"],
]
updated_html, updated = add_internal_links(html_content, data)
print(f'content updated {updated}')
if updated:
# Output the modified HTML
print(updated_html)
@@ -0,0 +1,57 @@
from io import BytesIO
from PIL import Image, ImageOps
def compress_image_bytes(image_bytes, extension, quality=90):
"""
Compress image binary data
Args:
image_bytes: Binary data of the image
Returns:
Compressed binary data of the image
"""
try:
with BytesIO(image_bytes) as img_buffer:
with Image.open(img_buffer) as img:
# Correct image orientation based on EXIF data, if available
# This ensures the image is properly oriented after conversion
img = ImageOps.exif_transpose(img)
output_buffer = BytesIO()
# PIL/pillow can only recognize JPEG, it does not know JPG...
if extension == 'jpg':
extension = 'jpeg'
img.save(output_buffer, format=extension, optimize=True, quality=quality)
compressed_image_bytes = output_buffer.getvalue()
return compressed_image_bytes
except Exception as e:
print("Error compressing image binary data:", str(e))
return None
def get_extension_from_image_mime(mime):
"""
reverse process of
https://github.com/zadam/trilium/blob/25b49e1ca28323f1a468968c8d918dcd8330d5c7/src/services/image.js#L60
:param mime:
:return:
"""
mime = mime.lower()
if mime == 'image/svg+xml':
return 'svg'
else:
return mime.split('/')[1]
if __name__ == "__main__":
image_file = '/home/nate/data/1/1.jpg'
output_file = '/home/nate/data/1/1.webp'
with open(image_file, 'rb') as f:
image_bytes = f.read()
compressed_image_bytes = compress_image_bytes(image_bytes, 'webp', 90)
with open(output_file, 'wb') as f:
f.write(compressed_image_bytes)
@@ -0,0 +1,188 @@
import re
from typing import List, Optional
from markdown2 import markdown
# It's part of
# https://github.com/constantAmateur/markdown2Mathjax/blob/master/lib/markdown2Mathjax.py
def break_tie(inline, equation):
"""If one of the delimiters is a substring of the other (e.g., $ and $$) it is possible that
the two will begin at the same location. In this case we need some criteria to break the tie
and decide which operation takes precedence. I've gone with the longer of the two delimiters
takes priority (for example, $$ over $). This function should return a 2 for the equation
block taking precedence, a 1 for the inline block. The magic looking return statement is
to map 0->2 and 1->1.
"""
tmp = inline.end() - inline.start() > equation.end() - equation.start()
return (tmp * 3 + 2) % 4
def markdown_safe(placeholder):
"""Is the placeholder changed by markdown? If it is, this isn't a valid placeholder."""
mdstrip = re.compile("<p>(.*)</p>\n")
md = markdown(placeholder)
mdp = mdstrip.match(md)
if mdp and mdp.group(1) == placeholder:
return True
return False
def sanitizeInput(
string,
inline_delims: Optional[List[str]] = None,
equation_delims: Optional[List[str]] = None,
placeholder="$0$",
):
"""Given a string that will be passed to markdown, the content of the different math blocks
is stripped out and replaced by a placeholder which MUST be ignored by markdown. A list
is returned containing the text with placeholders and a list of the stripped out equations.
Note that any pre-existing instances of the placeholder are "replaced" with themselves
and a corresponding dummy entry is placed in the returned codeblock. The sanitized string
can then be passed safety through markdown and then reconstructed with reconstructMath.
There are potential four delimiters that can be specified. The left and right delimiters
for inline and equation mode math. These can potentially be anything that isn't already
used by markdown and is compatible with mathjax (see documentation for both).
"""
inline_delims = inline_delims or ["$", "$"]
equation_delims = equation_delims or ["$$", "$$"]
# Check placeholder is valid.
if not markdown_safe(placeholder):
raise ValueError("Placeholder %s altered by markdown processing." % placeholder)
# really what we want is a reverse markdown function, but as that's too much work, this will do
inline_left = re.compile("(?<!\\\\)" + re.escape(inline_delims[0]))
inline_right = re.compile("(?<!\\\\)" + re.escape(inline_delims[1]))
equation_left = re.compile("(?<!\\\\)" + re.escape(equation_delims[0]))
equation_right = re.compile("(?<!\\\\)" + re.escape(equation_delims[1]))
placeholder_re = re.compile("(?<!\\\\)" + re.escape(placeholder))
placeholder_scan = placeholder_re.scanner(string)
ilscanner = [inline_left.scanner(string), inline_right.scanner(string)]
eqscanner = [equation_left.scanner(string), equation_right.scanner(string)]
scanners = [placeholder_scan, ilscanner, eqscanner]
# There are 3 types of blocks, inline math, equation math and occurrences of the
# placeholder in the text inBlack is 0 for a placeholder, 1 for inline block, 2 for equation
inBlock = 0
post = -1
stlen = len(string)
startmatches = [placeholder_scan.search(), ilscanner[0].search(), eqscanner[0].search()]
startpoints = [stlen, stlen, stlen]
startpoints[0] = startmatches[0].start() if startmatches[0] else stlen
startpoints[1] = startmatches[1].start() if startmatches[1] else stlen
startpoints[2] = startmatches[2].start() if startmatches[2] else stlen
terminator = -1
sanitizedString = ''
codeblocks = []
while 1:
# find the next point of interest.
while startmatches[0] and startmatches[0].start() < post:
startmatches[0] = placeholder_scan.search()
startpoints[0] = startmatches[0].start() if startmatches[0] else stlen
while startmatches[1] and startmatches[1].start() < post:
startmatches[1] = ilscanner[0].search()
startpoints[1] = startmatches[1].start() if startmatches[1] else stlen
while startmatches[2] and startmatches[2].start() < post:
startmatches[2] = eqscanner[0].search()
startpoints[2] = startmatches[2].start() if startmatches[2] else stlen
# Found start of next block of each type
# Placeholder type always takes precedence if it exists and is next...
if startmatches[0] and min(startpoints) == startpoints[0]:
# We can do it all in one!
# First add the "stripped" code to the blocks
codeblocks.append('0' + placeholder)
# Work out where the placeholder ends
tmp = startpoints[0] + len(placeholder)
# Add the "sanitized" text up to and including the placeholder
sanitizedString = sanitizedString + string[post * (post >= 0) : tmp]
# Set the new post
post = tmp
# Back to start!
continue
elif startmatches[1] is None and startmatches[2] is None:
# No more blocks, add in the rest of string and be done with it...
sanitizedString = sanitizedString + string[post * (post >= 0) :]
return (sanitizedString, codeblocks)
elif startmatches[1] is None:
inBlock = 2
elif startmatches[2] is None:
inBlock = 1
else:
inBlock = (startpoints[1] < startpoints[2]) + (startpoints[1] > startpoints[2]) * 2
if not inBlock:
inBlock = break_tie(startmatches[1], startmatches[2])
# Magic to ensure minimum index is 0
sanitizedString = sanitizedString + string[(post * (post >= 0)) : startpoints[inBlock]]
post = startmatches[inBlock].end()
# Now find the matching end...
while terminator < post:
endpoint = scanners[inBlock][1].search()
# If we run out of terminators before ending this loop, we're done
if endpoint is None:
# Add the unterminated codeblock to the sanitized string
sanitizedString = sanitizedString + string[startpoints[inBlock] :]
return (sanitizedString, codeblocks)
terminator = endpoint.start()
# We fonud a matching endpoint, add the bit to the appropriate codeblock...
codeblocks.append(str(inBlock) + string[post : endpoint.start()])
# Now add in the appropriate placeholder
sanitizedString = sanitizedString + placeholder
# Fabulous. Now we can start again once we update post...
post = endpoint.end()
def reconstructMath(
processedString,
codeblocks,
inline_delims: Optional[List[str]] = None,
equation_delims: Optional[List[str]] = None,
placeholder="$0$",
):
"""This is usually the output of sanitizeInput, after having passed the output string through
markdown. The delimiters given to this function should match those used to construct the
string to begin with.
This will output a string containing html suitable to use with mathjax.
"<" and ">" "&" symbols in math can confuse the html interpreter because they mark the
beginning and end of definition blocks. To avoid issues, if htmlSafe is set to True these
symbols will be replaced by ascii codes in the math blocks. The downside to this is that if
anyone is already doing this, there already formatted text might be mangled (I think I've taken
steps to make sure it won't but not extensively tested...)
"""
inline_delims = inline_delims or ['<span class="math-tex">\\(', '\\)</span>']
equation_delims = equation_delims or ['<span class="math-tex">\\[', '\\]</span>']
delims = [['', ''], inline_delims, equation_delims]
placeholder_re = re.compile("(?<!\\\\)" + re.escape(placeholder))
# If we've defined some "new" special characters we'll have to process any escapes of them here
# Make html substitutions.
# if htmlSafe:
# safeAmp=re.compile("&(?!(?:amp;|lt;|gt;))")
# for i in range(len(codeblocks)):
# codeblocks[i]=safeAmp.sub("&amp;",codeblocks[i])
# codeblocks[i]=codeblocks[i].replace("<","&lt;")
# codeblocks[i]=codeblocks[i].replace(">","&gt;")
# Step through the codeblocks one at a time and replace the next occurrence of the placeholder.
# Extra placeholders are invalid math blocks and ignored...
outString = ''
scan = placeholder_re.scanner(processedString)
post = 0
for i in range(len(codeblocks)):
inBlock = int(codeblocks[i][0])
match = scan.search()
if not match:
raise ValueError("More codeblocks given than valid placeholders in text.")
outString = (
outString
+ processedString[post : match.start()]
+ delims[inBlock][0]
+ codeblocks[i][1:].strip()
+ delims[inBlock][1]
)
post = match.end()
# Add the rest of the string (if we need to)
if post < len(processedString):
outString = outString + processedString[post:]
return outString
@@ -0,0 +1,170 @@
import re
import html
from bs4 import BeautifulSoup
from .html_util import sort_h_tags_with_hierarchy
def add_br(match):
return match.group(0).replace("\n", "<br>\n")
def beautify_content(content):
"""
Beautify note content (excluding <pre> blocks except trimming inside <code>):
- Normalize heading levels so the highest becomes h2
- Clean redundant empty lines
- Add new line before headings (idempotent, no duplication)
:param content: The HTML content to be beautified.
:return: Beautified HTML content.
"""
# Extract <pre> blocks and store them in a dictionary
pre_blocks = {}
def _extract_pre(m):
block = m.group(0)
key = f"__PRE_BLOCK_{len(pre_blocks)}__"
# trim empty lines in <pre><code>
block = re.sub(
r'(<pre.*?><code.*?>)\n*([\s\S]*?)\n*(</code></pre>)',
lambda mm: mm.group(1) + mm.group(2) + mm.group(3),
block
)
pre_blocks[key] = block
return key
# Beautify content
content = re.sub(r"<pre.*?>.*?</pre>", _extract_pre, content, flags=re.DOTALL)
# Use html module to unescape HTML entities (like &nbsp;)
content = html.unescape(content)
# Normalize heading levels
headings = re.findall(r'<h([2-6])', content)
if headings:
min_heading = min(int(h) for h in headings)
if min_heading > 2:
shift = min_heading - 2
def replace_heading(m):
level = int(m.group(2))
new_level = max(2, level - shift)
return f"{m.group(1)}h{new_level}{m.group(3)}"
content = re.sub(r'(<\/?)h([2-6])(>)', replace_heading, content)
# Remove redundant <p> before headings
for heading_level in range(2, 6):
content = re.sub(
fr'(?:<p>\s*</p>\s*)+(<h{heading_level}>)',
r'\1',
content
)
# Ensure one empty <p></p> before headings (but no duplicates)
for heading_level in range(2, 6):
content = re.sub(
fr'(?<!<p></p>)(<h{heading_level}>)',
r'<p></p>\1',
content
)
# remove redundant new line in code block
content = content.replace('\n</code></pre>', '</code></pre>')
# add new line to image
content = content.replace(' <img', '</p><p><img')
# remove redundant empty line
content = content.replace('<p> </p><p>&nbsp;</p>', '<p>&nbsp;</p>')
content = content.replace('<p>&nbsp;</p><p>&nbsp;</p>', '<p>&nbsp;</p>')
# remove redundant beginning
content = re.sub('^<p></p><h2>', '<h2>', content)
content = re.sub('^<div><div><p></p><h2>', '<h2>', content)
# Assemble pre blocks
for key, block in pre_blocks.items():
content = content.replace(key, block)
# Add line breaks in Paragraph
content = re.sub(r"<p>.*?</p>", add_br, content, flags=re.DOTALL)
return content
def sort_note_by_headings(html_content, locale_str='zh_CN.UTF-8'):
"""
Sorts note content order by the name of headings, following the rules of the input language.
:param html_content: The HTML content to be sorted.
:param locale_str: Should be something like 'zh_CN.UTF-8', which is the Chinese Pinyin order.
:return: The sorted HTML content as a string.
"""
soup = BeautifulSoup(html_content, 'html.parser')
# Find all h tags
h_tags = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
# Split the content by h tags
result_list = []
for i, h_tag in enumerate(h_tags):
current_h = str(h_tag)
# The next h tag (if it exists)
next_h_tag = h_tags[i + 1] if i + 1 < len(h_tags) else None
# The position of the next h tag in the HTML content
next_h_index = html_content.find(str(next_h_tag)) if next_h_tag else None
# Extract the h tag and the content after it
if next_h_index:
content_after_h = html_content[html_content.find(str(h_tag)): next_h_index]
else:
# If there is no next h tag, extract the h tag and all content after it
content_after_h = html_content[html_content.find(str(h_tag)):]
# result_list.append([current_h, content_after_h])
result_list.append(content_after_h)
# Extract the content before the first h tag
first_h_index = html_content.find(str(h_tags[0]))
content_before_first_h = html_content[:first_h_index]
# Sort the h tags
sorted_html = sort_h_tags_with_hierarchy(result_list, locale_str)
# Assemble the parts
sorted_html_string = content_before_first_h + sorted_html
return sorted_html_string
def preprocess_note_title_list(data):
"""
Optimized version of the function to preprocess the list of [title, note_id].
Cleans titles, removes duplicates and previous matching entries, and sorts by title length.
"""
def clean_title(title):
return title.strip()
# Use an ordered dictionary to maintain insertion order while ensuring uniqueness
from collections import OrderedDict
cleaned_data = OrderedDict()
# Traverse the data and process each title
for title, note_id in data:
cleaned_title = clean_title(title)
if cleaned_title in cleaned_data:
# If the title already exists, remove it
del cleaned_data[cleaned_title]
else:
# Otherwise, add it to the dictionary
cleaned_data[cleaned_title] = note_id
# Convert the dictionary back to a list and sort by title length (descending)
return sorted(cleaned_data.items(), key=lambda x: len(x[0]), reverse=True)
@@ -0,0 +1,32 @@
import json
def format_query_string(params):
"""
convert
{"search": "root", "fastSearch": False}
to
{"search": "root", "fastSearch": "false"}
cause trilium takes "false" and "true" instead of "False" and "True"
:param params:
:return:
"""
json_str = (
str(json.dumps(params, ensure_ascii=False))
.replace(': false', ': "false"')
.replace(': true', ': "true"')
)
params = json.loads(json_str)
return params
def clean_param(params):
clean_list = []
for k, v in params.items():
if not v:
clean_list.append(k)
for x in clean_list:
params.pop(x)
return params
@@ -0,0 +1,188 @@
import dateutil
from datetime import timedelta, datetime
from typing import Optional, Tuple, Union
from loguru import logger
def get_today() -> str:
"""
Get today's date in YYYY-MM-DD format.
Returns:
str: Today's date as a string in format "%Y-%m-%d"
"""
return datetime.now().strftime("%Y-%m-%d")
def get_yesterday() -> str:
"""
Get yesterday's date in YYYY-MM-DD format.
Returns:
str: Yesterday's date as a string in format "%Y-%m-%d"
"""
return (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
def get_local_timezone():
"""
Get the local timezone.
Returns:
tzinfo: The local timezone info
"""
logger.debug("Getting local timezone")
local_timezone = datetime.now().astimezone().tzinfo
logger.debug(f"Local timezone: {local_timezone}")
return local_timezone
def ensure_timezone(dt: datetime, tz=None) -> datetime:
"""
Ensure a datetime object has timezone information.
Args:
dt (datetime): The datetime object to check
tz: The timezone to use if dt has no timezone. If None, use local timezone.
Returns:
datetime: A datetime object with timezone information
"""
if dt.tzinfo is None:
if tz is None:
tz = get_local_timezone()
dt = dt.replace(tzinfo=tz)
logger.debug(f"Added timezone to datetime: {dt}")
return dt
def handle_dates(
dateCreated: Optional[datetime] = None,
utcDateCreated: Optional[datetime] = None
) -> Tuple[Optional[datetime], Optional[datetime]]:
"""
Ensure that both local and UTC times are defined and have the same time
(adjusted for timezone).
Args:
dateCreated (datetime, optional): Local datetime
utcDateCreated (datetime, optional): UTC datetime
Returns:
tuple: (local_datetime, utc_datetime)
Raises:
TypeError: If dateCreated or utcDateCreated is not a datetime object
"""
if not dateCreated and not utcDateCreated:
return None, None
if dateCreated and not isinstance(dateCreated, datetime):
logger.error(f"dateCreated is not datetime object, is {type(dateCreated)}")
raise TypeError("dateCreated must be a datetime object")
if utcDateCreated and not isinstance(utcDateCreated, datetime):
logger.error(f"utcDateCreated is not datetime object, is {type(utcDateCreated)}")
raise TypeError("utcDateCreated must be a datetime object")
# Ensure timezone info is set
if dateCreated:
dateCreated = ensure_timezone(dateCreated)
if utcDateCreated:
utcDateCreated = ensure_timezone(utcDateCreated, dateutil.tz.tzutc())
# Synchronize dates
return synchronize_dates(local_date=dateCreated, utc_date=utcDateCreated)
def synchronize_dates(
local_date: Optional[datetime],
utc_date: Optional[datetime]
) -> Tuple[Optional[datetime], Optional[datetime]]:
"""
Synchronize local and UTC dates. We expect only one of local or utc date to
be passed, use that to define the other, and return both as datetime objects.
Args:
local_date (datetime, optional): Local datetime with timezone info
utc_date (datetime, optional): UTC datetime with timezone info
Returns:
tuple: (local_datetime, utc_datetime)
Raises:
ValueError: If both local_date and utc_date are provided
"""
if local_date and utc_date:
msg = "Both local and UTC dates were provided, cannot determine which to use.\n"
msg = msg + "Please pass only one of local or UTC date."
logger.error(msg)
raise ValueError(msg)
local_timezone = get_local_timezone()
if local_date and not utc_date:
utc_date = local_date.astimezone(dateutil.tz.tzutc())
elif utc_date and not local_date:
local_date = utc_date.astimezone(local_timezone)
elif local_date and utc_date != utc_date.astimezone(dateutil.tz.tzlocal()):
logger.error("local_date and utc_date are inconsistent")
raise ValueError("local_date and utc_date are inconsistent.")
logger.debug(f"Synchronized dates: local={local_date}, utc={utc_date}")
return local_date, utc_date
def format_date_to_etapi(date: datetime, kind: str) -> str:
"""
From a datetime object, return a date string formatted to ETAPI requirements.
Args:
date (datetime): The datetime object to format
kind (str): Either 'local' or 'utc'
Returns:
str: Formatted date string
local: '2023-08-21 23:38:51.110-0200'
UTC : '2023-08-22 01:38:51.110Z'
"""
if kind == "local":
formatted_date = date.strftime("%Y-%m-%d %H:%M:%S.%d3%z")
elif kind == "utc":
date = date.astimezone(dateutil.tz.tzstr("Z")) # use Zulu time
formatted_date = date.strftime("%Y-%m-%d %H:%M:%S.%d3%Z")
else:
raise ValueError(f"Invalid kind: {kind}. Must be 'local' or 'utc'")
logger.debug(f"ETAPI Formatted date ({kind}): {formatted_date}")
return formatted_date
def format_dates_for_api(
local_date: Optional[datetime] = None,
utc_date: Optional[datetime] = None
) -> Tuple[Optional[str], Optional[str]]:
"""
Format dates for API calls. This is a convenience function that combines
handle_dates and format_date_to_etapi.
Args:
local_date (datetime, optional): Local datetime
utc_date (datetime, optional): UTC datetime
Returns:
tuple: (formatted_local_date, formatted_utc_date)
"""
if not local_date and not utc_date:
return None, None
local_dt, utc_dt = handle_dates(dateCreated=local_date, utcDateCreated=utc_date)
if not local_dt or not utc_dt:
return None, None
local_str = format_date_to_etapi(local_dt, kind='local')
utc_str = format_date_to_etapi(utc_dt, kind='utc')
return local_str, utc_str
@@ -0,0 +1,22 @@
import json
def format_query_string(params):
"""
convert
{"search": "root", "fastSearch": False}
to
{"search": "root", "fastSearch": "false"}
cause trilium takes "false" and "true" instead of "False" and "True"
:param params:
:return:
"""
json_str = (
str(json.dumps(params, ensure_ascii=False))
.replace(': false', ': "false"')
.replace(': true', ': "true"')
)
params = json.loads(json_str)
return params
@@ -0,0 +1,6 @@
"""Version information for trilium-py package.
This module contains a single variable, __version__, which is the version of the package.
This allows for a single source of truth for the package version.
"""
__version__ = '1.3.9'
@@ -0,0 +1,161 @@
import re
import sys
from typing import Optional
import requests
from loguru import logger
from .version import __version__
class WEBAPI:
__version__ = __version__
def __init__(self, server_url: str, sid: Optional[str] = None, _csrf: Optional[str] = None,
csrf_token: Optional[str] = None):
if sys.version_info < (3, 9):
print(
(
f'You are using Python {sys.version_info.major}.{sys.version_info.minor}'
', 3.9+ is required.'
),
file=sys.stderr,
)
self.server_url = server_url
self.sid: str = sid
self._csrf = _csrf
self.csrf_token = csrf_token
def get_cookie(self) -> dict:
return {
'_csrf': self._csrf,
'trilium.sid': self.sid,
'trilium-device': 'desktop',
# Trilium next uses this cookie
'trilium-csrf': self.csrf_token,
}
def get_headers(self) -> dict:
return {
'x-csrf-token': self.csrf_token,
}
def refresh_csrf_token(self) -> str:
# Classic Trilium 0.63.7, the csrfToken is from `/` endpoint
url = f'{self.server_url}/'
res = requests.get(url, cookies=self.get_cookie())
csrf_token_match = re.search(r"csrfToken:\s*'([^']+)'", res.text)
if csrf_token_match:
csrf_token = csrf_token_match.group(1)
logger.info(f"Extracted csrfToken: {csrf_token}")
self.csrf_token = csrf_token
return csrf_token
else:
logger.info("csrfToken not found.")
# Trilium next, the csrfToken is from `/bootstrap` endpoint
logger.info("Trying to extract csrfToken from /bootstrap endpoint...")
url = f'{self.server_url}/bootstrap'
res = requests.get(url, cookies=self.get_cookie())
csrf_token = res.json()['csrfToken']
logger.info(f"Extracted csrfToken: {csrf_token}")
self.csrf_token = csrf_token
return csrf_token
def login(self, password: str) -> Optional[str]:
"""
mimic web login
"""
url = f'{self.server_url}/login'
data = {'password': password}
# login process is in 2-step
# 1. 302 set-cookie trilium.sid
# 2. 200 set-cookie _csrf
# single requests will not work, need to use session
session = requests.Session()
res = session.post(url, data=data, allow_redirects=True)
for cookie in session.cookies:
logger.info(f"{cookie.name}: {cookie.value}")
if res.status_code == 200:
self.sid = session.cookies.get('trilium.sid')
self._csrf = session.cookies.get('_csrf')
self.refresh_csrf_token()
return self.sid
else:
logger.info(res.text)
return ''
def logout(self, sid: Optional[str] = None) -> bool:
"""
mimic web logout
"""
if not sid:
sid = self.sid
if not sid:
return False
url = f'{self.server_url}/logout'
res = requests.post(url, headers=self.get_headers(), cookies=self.get_cookie())
if res.status_code == 200:
logger.info('logout successfully')
return True
return False
def get_note_content(self, note_id):
url = f'{self.server_url}/api/notes/{note_id}/blob'
res = requests.get(url, cookies=self.get_cookie())
return res.json()['content']
def update_note_content(self, note_id, content):
url = f'{self.server_url}/api/notes/{note_id}/data'
data = {'content': content}
res = requests.put(url, headers=self.get_headers(), cookies=self.get_cookie(), json=data)
if res.status_code == 204:
return True
return False
def share_note(self, note_id: str):
url = f'{self.server_url}/api/notes/{note_id}/clone-to-note/_share'
res = requests.put(url, headers=self.get_headers(), cookies=self.get_cookie())
logger.info(res.json())
if res.json():
return True
return False
def cancel_share_note(self, note_id: str):
url = f'{self.server_url}/api/branches/_share_{note_id}?taskId=no-progress-reporting'
res = requests.delete(url, headers=self.get_headers(), cookies=self.get_cookie())
logger.info(res.json())
if res.status_code == 200: # DELETE 请求通常返回 200 表示成功
return True
return False
def enter_protected_session(self, password: str):
url = f'{self.server_url}/api/login/protected'
data = {'password': password}
res = requests.post(url, headers=self.get_headers(), cookies=self.get_cookie(), json=data)
try:
if res.json()['success'] == True:
return True
else:
return False
except:
return False
def leave_protected_session(self):
url = f'{self.server_url}/api/logout/protected'
res = requests.post(url, headers=self.get_headers(), cookies=self.get_cookie())
if res.status_code == 204:
logger.info('leave protected session successfully')
return True
return False