mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 08:38:32 +02:00
* Add ruff * Add ruff config * Add ruff rules that look useful and are already passing * Add more ruff rules after talking with Jonathan * Add line length exception for acceptably long indented line * Resolve ruff line length 88 rule in args. Changing small lines but adding a noqa ignore directive to longer lines that look best as they are. Their dedented length is still less than 88 * poe format * Resolve all remaining ruff line length errors * Replace flake* and isort with ruff calls * Add black --check as final lint step. ruff catches most but not all black formatting issues * Remove unneeded flakeheaven setting * Remove flake* and isort now that ruff is handling all their business * Update pyproject, lockfile with latest version of ruff * Document each ruff rule with comment * Add black --version call before black --check * Remove extraneous period
250 lines
7.9 KiB
Python
250 lines
7.9 KiB
Python
# Copyright © 2012-2023 jrnl contributors
|
|
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import TYPE_CHECKING
|
|
|
|
import ansiwrap
|
|
|
|
from jrnl.color import colorize
|
|
from jrnl.color import highlight_tags_with_background_color
|
|
|
|
if TYPE_CHECKING:
|
|
from .Journal import Journal
|
|
|
|
|
|
class Entry:
|
|
def __init__(
|
|
self,
|
|
journal: "Journal",
|
|
date: datetime.datetime | None = None,
|
|
text: str = "",
|
|
starred: bool = False,
|
|
):
|
|
self.journal = journal # Reference to journal mainly to access its config
|
|
self.date = date or datetime.datetime.now()
|
|
self.text = text
|
|
self._title = None
|
|
self._body = None
|
|
self._tags = None
|
|
self.starred = starred
|
|
self.modified = False
|
|
|
|
@property
|
|
def fulltext(self) -> str:
|
|
return self.title + " " + self.body
|
|
|
|
def _parse_text(self):
|
|
raw_text = self.text
|
|
lines = raw_text.splitlines()
|
|
if lines and lines[0].strip().endswith("*"):
|
|
self.starred = True
|
|
raw_text = lines[0].strip("\n *") + "\n" + "\n".join(lines[1:])
|
|
self._title, self._body = split_title(raw_text)
|
|
if self._tags is None:
|
|
self._tags = list(self._parse_tags())
|
|
|
|
@property
|
|
def title(self) -> str:
|
|
if self._title is None:
|
|
self._parse_text()
|
|
return self._title
|
|
|
|
@title.setter
|
|
def title(self, x: str):
|
|
self._title = x
|
|
|
|
@property
|
|
def body(self) -> str:
|
|
if self._body is None:
|
|
self._parse_text()
|
|
return self._body
|
|
|
|
@body.setter
|
|
def body(self, x: str):
|
|
self._body = x
|
|
|
|
@property
|
|
def tags(self) -> list[str]:
|
|
if self._tags is None:
|
|
self._parse_text()
|
|
return self._tags
|
|
|
|
@tags.setter
|
|
def tags(self, x: list[str]):
|
|
self._tags = x
|
|
|
|
@staticmethod
|
|
def tag_regex(tagsymbols: str) -> re.Pattern:
|
|
pattern = rf"(?<!\S)([{tagsymbols}][-+*#/\w]+)"
|
|
return re.compile(pattern)
|
|
|
|
def _parse_tags(self) -> set[str]:
|
|
tagsymbols = self.journal.config["tagsymbols"]
|
|
return {
|
|
tag.lower() for tag in re.findall(Entry.tag_regex(tagsymbols), self.text)
|
|
}
|
|
|
|
def __str__(self):
|
|
"""Returns string representation of the entry to be written to journal file."""
|
|
date_str = self.date.strftime(self.journal.config["timeformat"])
|
|
title = "[{}] {}".format(date_str, self.title.rstrip("\n "))
|
|
if self.starred:
|
|
title += " *"
|
|
return "{title}{sep}{body}\n".format(
|
|
title=title,
|
|
sep="\n" if self.body.rstrip("\n ") else "",
|
|
body=self.body.rstrip("\n "),
|
|
)
|
|
|
|
def pprint(self, short: bool = False) -> str:
|
|
"""Returns a pretty-printed version of the entry.
|
|
If short is true, only print the title."""
|
|
# Handle indentation
|
|
if self.journal.config["indent_character"]:
|
|
indent = self.journal.config["indent_character"].rstrip() + " "
|
|
else:
|
|
indent = ""
|
|
|
|
date_str = colorize(
|
|
self.date.strftime(self.journal.config["timeformat"]),
|
|
self.journal.config["colors"]["date"],
|
|
bold=True,
|
|
)
|
|
|
|
if not short and self.journal.config["linewrap"]:
|
|
columns = self.journal.config["linewrap"]
|
|
|
|
if columns == "auto":
|
|
try:
|
|
columns = os.get_terminal_size().columns
|
|
except OSError:
|
|
logging.debug(
|
|
"Can't determine terminal size automatically 'linewrap': '%s'",
|
|
self.journal.config["linewrap"],
|
|
)
|
|
columns = 79
|
|
|
|
# Color date / title and bold title
|
|
title = ansiwrap.fill(
|
|
date_str
|
|
+ " "
|
|
+ highlight_tags_with_background_color(
|
|
self,
|
|
self.title,
|
|
self.journal.config["colors"]["title"],
|
|
is_title=True,
|
|
),
|
|
columns,
|
|
)
|
|
body = highlight_tags_with_background_color(
|
|
self, self.body.rstrip(" \n"), self.journal.config["colors"]["body"]
|
|
)
|
|
body_text = [
|
|
colorize(
|
|
ansiwrap.fill(
|
|
line,
|
|
columns,
|
|
initial_indent=indent,
|
|
subsequent_indent=indent,
|
|
drop_whitespace=True,
|
|
),
|
|
self.journal.config["colors"]["body"],
|
|
)
|
|
or indent
|
|
for line in body.rstrip(" \n").splitlines()
|
|
]
|
|
|
|
# ansiwrap doesn't handle lines with only the "\n" character and some
|
|
# ANSI escapes properly, so we have this hack here to make sure the
|
|
# beginning of each line has the indent character and it's colored
|
|
# properly. textwrap doesn't have this issue, however, it doesn't wrap
|
|
# the strings properly as it counts ANSI escapes as literal characters.
|
|
# TL;DR: I'm sorry.
|
|
body = "\n".join(
|
|
[
|
|
colorize(indent, self.journal.config["colors"]["body"]) + line
|
|
if not ansiwrap.strip_color(line).startswith(indent)
|
|
else line
|
|
for line in body_text
|
|
]
|
|
)
|
|
else:
|
|
title = (
|
|
date_str
|
|
+ " "
|
|
+ highlight_tags_with_background_color(
|
|
self,
|
|
self.title.rstrip("\n"),
|
|
self.journal.config["colors"]["title"],
|
|
is_title=True,
|
|
)
|
|
)
|
|
body = highlight_tags_with_background_color(
|
|
self, self.body.rstrip("\n "), self.journal.config["colors"]["body"]
|
|
)
|
|
|
|
# Suppress bodies that are just blanks and new lines.
|
|
has_body = len(self.body) > 20 or not all(
|
|
char in (" ", "\n") for char in self.body
|
|
)
|
|
|
|
if short:
|
|
return title
|
|
else:
|
|
return "{title}{sep}{body}\n".format(
|
|
title=title, sep="\n" if has_body else "", body=body if has_body else ""
|
|
)
|
|
|
|
def __repr__(self):
|
|
return "<Entry '{}' on {}>".format(
|
|
self.title.strip(), self.date.strftime("%Y-%m-%d %H:%M")
|
|
)
|
|
|
|
def __hash__(self):
|
|
return hash(self.__repr__())
|
|
|
|
def __eq__(self, other: "Entry"):
|
|
if (
|
|
not isinstance(other, Entry)
|
|
or self.title.strip() != other.title.strip()
|
|
or self.body.rstrip() != other.body.rstrip()
|
|
or self.date != other.date
|
|
or self.starred != other.starred
|
|
):
|
|
return False
|
|
return True
|
|
|
|
def __ne__(self, other: "Entry"):
|
|
return not self.__eq__(other)
|
|
|
|
|
|
# Based on Segtok by Florian Leitner
|
|
# https://github.com/fnl/segtok
|
|
SENTENCE_SPLITTER = re.compile(
|
|
r"""
|
|
(
|
|
[.!?\u2026\u203C\u203D\u2047\u2048\u2049\u22EF\uFE52\uFE57] # Sequence starting with a sentence terminal,
|
|
[\'\u2019\"\u201D]? # an optional right quote,
|
|
[\]\)]* # optional closing bracket
|
|
\s+ # AND a sequence of required spaces.
|
|
)
|
|
|[\uFF01\uFF0E\uFF1F\uFF61\u3002] # CJK full/half width terminals usually do not have following spaces.
|
|
""", # noqa: E501
|
|
re.VERBOSE,
|
|
)
|
|
|
|
SENTENCE_SPLITTER_ONLY_NEWLINE = re.compile("\n")
|
|
|
|
|
|
def split_title(text: str) -> tuple[str, str]:
|
|
"""Splits the first sentence off from a text."""
|
|
sep = SENTENCE_SPLITTER_ONLY_NEWLINE.search(text.lstrip())
|
|
if not sep:
|
|
sep = SENTENCE_SPLITTER.search(text)
|
|
if not sep:
|
|
return text, ""
|
|
return text[: sep.end()].strip(), text[sep.end() :].strip()
|