"""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) # 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 PropertiesView(Found: Parsed, FontSize: int, MakeMarkdown: Callable[[str], ft.Control], Toggle: OnToggle) -> ft.Control: """The panel. MakeMarkdown renders a value (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(Entry, color=Palette.Mauve, 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 = MakeMarkdown(Item.Value) else: Shown = ft.Text("Empty", color=Palette.Overlay0, italic=True, size=FontSize - 1) Rows.append(ft.Row([ ft.Container(ft.Text(Item.Key, color=Palette.Overlay0, 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)) 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)]