"""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)\]") ImageLine = re.compile(r"^\s*!\[([^\]]*)\]\(]+?)>?\)\s*$") 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, LineMap: Optional[list[Optional[int]]] = None, MakeImage: Optional[Callable[[str, str], Optional[ft.Control]]] = None) -> list[ft.Control]: """Markdown with its task lines as real checkboxes, and lines that are only an image as the image (MakeImage): the lines between render as usual. FirstLine is where Text starts in the note; LineMap (Variables.Substitute) says which of its lines each shown line is, None for one from a variable's result (its checkbox can't be ticked).""" 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 # A line that's only an image: shown and clickable (MakeImage), as # Flet's Markdown has no click for images. Picture = ImageLine.match(Line) if MakeImage is not None and not InFence else None Shown = MakeImage(Picture.group(2), Picture.group(1)) if Picture else None if Shown is not None: Flush() Controls.append(Shown) continue 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()) SourceLine = Offset if LineMap is None else LineMap[Offset] Controls.append(ft.Row( [_Checkbox(Task.group(2) != " ", FirstLine + (SourceLine or 0), Toggle if SourceLine is not None else None), 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)]