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:
@@ -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("&",codeblocks[i])
|
||||
# codeblocks[i]=codeblocks[i].replace("<","<")
|
||||
# codeblocks[i]=codeblocks[i].replace(">",">")
|
||||
# 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 )
|
||||
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> </p>', '<p> </p>')
|
||||
content = content.replace('<p> </p><p> </p>', '<p> </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
|
||||
Reference in New Issue
Block a user