mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
203 lines
7.8 KiB
Python
203 lines
7.8 KiB
Python
"""Properties, like Obsidian's (and the web view's Frontend/src/Properties.js):
|
|
a block at the very top of a note,
|
|
|
|
---
|
|
Key: Value
|
|
Done: true
|
|
---
|
|
|
|
shown as a Properties panel instead of text. true/false values are
|
|
checkboxes, and so are task list items (- [ ]): both can be ticked without
|
|
editing the note. The parsing follows Properties.js so a note reads the same
|
|
in both."""
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, Optional
|
|
|
|
import flet as ft
|
|
|
|
from Core import Palette
|
|
|
|
OpenOrClose = re.compile(r"^---\s*$")
|
|
PropertyLine = re.compile(r"^([^:\s][^:]*?)\s*:\s*(.*)$")
|
|
TaskLine = re.compile(r"^(\s*(?:[-*+]|\d+[.)])\s+)\[( |x|X)\]")
|
|
BooleanValue = re.compile(r"^(true|false)$", re.IGNORECASE)
|
|
Quoted = re.compile(r"""^(["']).*\1$""")
|
|
IntegerValue = re.compile(r"^[+-]?\d+$")
|
|
FloatValue = re.compile(r"^[+-]?(\d+\.\d*([eE][+-]?\d+)?|\.\d+([eE][+-]?\d+)?|\d+[eE][+-]?\d+)$")
|
|
DateValue = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?$")
|
|
|
|
|
|
def ValueKind(Value: str) -> str:
|
|
"""What a value is, for its colour: quotes always make it text ("42" is
|
|
text, 42 a number), like YAML. Same rules as Properties.js's ValueKind."""
|
|
if Value == "":
|
|
return "Empty"
|
|
if Quoted.match(Value):
|
|
return "String"
|
|
if BooleanValue.match(Value):
|
|
return "Boolean"
|
|
if IntegerValue.match(Value):
|
|
return "Integer"
|
|
if FloatValue.match(Value):
|
|
return "Float"
|
|
if DateValue.match(Value):
|
|
return "Date"
|
|
return "String"
|
|
|
|
|
|
def ShownValue(Value: str) -> str:
|
|
"""A value as shown: without the quotes that only said "this is text"."""
|
|
return Value[1:-1] if Quoted.match(Value) else Value
|
|
|
|
|
|
def KindColour(Kind: str) -> str:
|
|
return {"Integer": Palette.Peach, "Float": Palette.Yellow, "Date": Palette.Blue}.get(Kind, Palette.Green)
|
|
|
|
# Ticks a box: called with the line (0-based, in the whole note) to flip.
|
|
OnToggle = Optional[Callable[[int], None]]
|
|
|
|
|
|
@dataclass
|
|
class Property:
|
|
Key: str
|
|
Value: str
|
|
Line: int
|
|
List: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Parsed:
|
|
Properties: list[Property]
|
|
EndLine: int # the closing ---
|
|
EndOffset: int # just after it
|
|
|
|
|
|
def ParseProperties(Text: str) -> Optional[Parsed]:
|
|
"""The block at the top, or None (also when it isn't all Key: Value
|
|
lines: then it's just a note that starts with a --- rule)."""
|
|
Lines = Text.split("\n")
|
|
if not OpenOrClose.match(Lines[0]):
|
|
return None
|
|
EndLine = next((Index for Index in range(1, len(Lines)) if OpenOrClose.match(Lines[Index])), None)
|
|
if EndLine is None:
|
|
return None
|
|
Found: list[Property] = []
|
|
for Index in range(1, EndLine):
|
|
Line = Lines[Index]
|
|
Match = PropertyLine.match(Line)
|
|
if Match:
|
|
Found.append(Property(Match.group(1), Match.group(2).strip(), Index))
|
|
elif Line.strip().startswith("- ") and Found:
|
|
Found[-1].List.append(Line.strip()[2:].strip()) # a list under the previous key
|
|
elif Line.strip():
|
|
return None
|
|
return Parsed(Found, EndLine, len("\n".join(Lines[:EndLine + 1])))
|
|
|
|
|
|
def ToggleLine(Text: str, LineIndex: int) -> str:
|
|
"""Flips a task's [ ]/[x], or a true/false property, on one line."""
|
|
Lines = Text.split("\n")
|
|
if not 0 <= LineIndex < len(Lines):
|
|
return Text
|
|
Line = Lines[LineIndex]
|
|
Task = TaskLine.match(Line)
|
|
if Task:
|
|
Mark = "x" if Task.group(2) == " " else " "
|
|
Lines[LineIndex] = f"{Task.group(1)}[{Mark}]{Line[Task.end():]}"
|
|
else:
|
|
Lines[LineIndex] = re.sub(r"(:\s*)(true|false)(\s*)$",
|
|
lambda M: M.group(1) + ("false" if M.group(2).lower() == "true" else "true") + M.group(3),
|
|
Line, flags=re.IGNORECASE)
|
|
return "\n".join(Lines)
|
|
|
|
|
|
def _Checkbox(Checked: bool, Line: int, Toggle: OnToggle) -> ft.Checkbox:
|
|
return ft.Checkbox(
|
|
value=Checked,
|
|
disabled=Toggle is None,
|
|
active_color=Palette.Mauve,
|
|
check_color=Palette.Base,
|
|
visual_density=ft.VisualDensity.COMPACT,
|
|
on_change=(lambda e, L=Line: Toggle(L)) if Toggle else None,
|
|
)
|
|
|
|
|
|
def _Value(Value: str, FontSize: int, MakeMarkdown: Callable[[str], ft.Control]) -> ft.Control:
|
|
"""A value in its kind's colour; one with Markdown in it (a link, bold)
|
|
is rendered as Markdown instead."""
|
|
Shown = ShownValue(Value)
|
|
if re.search(r"[\[*_`~]|https?://", Shown):
|
|
return MakeMarkdown(Shown)
|
|
return ft.Text(Shown, color=KindColour(ValueKind(Value)), size=FontSize, selectable=True)
|
|
|
|
|
|
def PropertiesView(Found: Parsed, FontSize: int, MakeMarkdown: Callable[[str], ft.Control],
|
|
Toggle: OnToggle) -> ft.Control:
|
|
"""The panel. MakeMarkdown renders a value with Markdown in it (links, bold)."""
|
|
Rows: list[ft.Control] = []
|
|
for Item in Found.Properties:
|
|
if BooleanValue.match(Item.Value):
|
|
Shown: ft.Control = _Checkbox(Item.Value.lower() == "true", Item.Line, Toggle)
|
|
elif Item.List:
|
|
Shown = ft.Row([ft.Container(ft.Text(ShownValue(Entry), color=KindColour(ValueKind(Entry)), size=FontSize - 1),
|
|
bgcolor=Palette.Surface0, border_radius=4,
|
|
padding=ft.Padding.symmetric(horizontal=6, vertical=1))
|
|
for Entry in Item.List], wrap=True, spacing=4, run_spacing=4)
|
|
elif Item.Value:
|
|
Shown = _Value(Item.Value, FontSize, MakeMarkdown)
|
|
else:
|
|
Shown = ft.Text("Empty", color=Palette.Overlay0, italic=True, size=FontSize - 1)
|
|
Rows.append(ft.Container(
|
|
ft.Row([
|
|
ft.Container(ft.Text(Item.Key, color=Palette.Text, size=FontSize - 1, max_lines=1,
|
|
overflow=ft.TextOverflow.ELLIPSIS), expand=3),
|
|
ft.Container(Shown, expand=7, alignment=ft.Alignment.CENTER_LEFT),
|
|
], spacing=12, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
|
# A line between properties, like the web view's.
|
|
border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)) if Rows else None,
|
|
padding=ft.Padding.symmetric(vertical=4),
|
|
))
|
|
return ft.Container(
|
|
ft.Column([ft.Text("Properties", color=Palette.Overlay0, size=FontSize - 1), *Rows], spacing=2, tight=True),
|
|
bgcolor=Palette.Mantle,
|
|
border=ft.Border.all(1, Palette.Surface1),
|
|
border_radius=4,
|
|
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
|
|
)
|
|
|
|
|
|
def WithCheckboxes(Text: str, FirstLine: int, MakeMarkdown: Callable[[str], ft.Control],
|
|
Toggle: OnToggle, FontSize: int) -> list[ft.Control]:
|
|
"""Markdown with its task lines as real checkboxes: the lines between
|
|
them render as usual. FirstLine is where Text starts in the note."""
|
|
Controls: list[ft.Control] = []
|
|
Pending: list[str] = []
|
|
InFence = False
|
|
|
|
def Flush() -> None:
|
|
if "".join(Pending).strip():
|
|
Controls.append(MakeMarkdown("\n".join(Pending)))
|
|
Pending.clear()
|
|
|
|
for Offset, Line in enumerate(Text.split("\n")):
|
|
if re.match(r"^ {0,3}(`{3,}|~{3,})", Line):
|
|
InFence = not InFence
|
|
Task = None if InFence else TaskLine.match(Line)
|
|
if Task is None:
|
|
Pending.append(Line)
|
|
continue
|
|
Flush()
|
|
Indent = len(Task.group(1)) - len(Task.group(1).lstrip())
|
|
Controls.append(ft.Row(
|
|
[_Checkbox(Task.group(2) != " ", FirstLine + Offset, Toggle),
|
|
ft.Container(MakeMarkdown(Line[Task.end():].strip() or " "), expand=True)],
|
|
spacing=4,
|
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
))
|
|
if Indent:
|
|
Controls[-1] = ft.Container(Controls[-1], padding=ft.Padding.only(left=Indent * 8))
|
|
Flush()
|
|
return Controls or [MakeMarkdown(Text)]
|