mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 16:48:31 +02:00
clean up more files
This commit is contained in:
parent
0794b5ca4b
commit
3be496e8c3
47 changed files with 0 additions and 5114 deletions
|
@ -1,8 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
try:
|
|
||||||
from jrnl.__version__ import __version__
|
|
||||||
except ImportError:
|
|
||||||
__version__ = "source"
|
|
||||||
__title__ = "jrnl"
|
|
|
@ -1,9 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from jrnl.main import run
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(run())
|
|
|
@ -1 +0,0 @@
|
||||||
__version__ = "v4.0-beta3"
|
|
446
jrnl/args.py
446
jrnl/args.py
|
@ -1,446 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
from jrnl.commands import postconfig_decrypt
|
|
||||||
from jrnl.commands import postconfig_encrypt
|
|
||||||
from jrnl.commands import postconfig_import
|
|
||||||
from jrnl.commands import postconfig_list
|
|
||||||
from jrnl.commands import preconfig_diagnostic
|
|
||||||
from jrnl.commands import preconfig_version
|
|
||||||
from jrnl.output import deprecated_cmd
|
|
||||||
from jrnl.plugins import EXPORT_FORMATS
|
|
||||||
from jrnl.plugins import IMPORT_FORMATS
|
|
||||||
from jrnl.plugins import util
|
|
||||||
|
|
||||||
|
|
||||||
class WrappingFormatter(argparse.RawTextHelpFormatter):
|
|
||||||
"""Used in help screen"""
|
|
||||||
|
|
||||||
def _split_lines(self, text: str, width: int) -> list[str]:
|
|
||||||
text = text.split("\n\n")
|
|
||||||
text = map(lambda t: self._whitespace_matcher.sub(" ", t).strip(), text)
|
|
||||||
text = map(lambda t: textwrap.wrap(t, width=56), text)
|
|
||||||
text = [item for sublist in text for item in sublist]
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
class IgnoreNoneAppendAction(argparse._AppendAction):
|
|
||||||
"""
|
|
||||||
Pass -not without a following string and avoid appending
|
|
||||||
a None value to the excluded list
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __call__(self, parser, namespace, values, option_string=None):
|
|
||||||
if values is not None:
|
|
||||||
super().__call__(parser, namespace, values, option_string)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_not_arg(
|
|
||||||
args: list[str], parsed_args: argparse.Namespace, parser: argparse.ArgumentParser
|
|
||||||
) -> argparse.Namespace:
|
|
||||||
"""
|
|
||||||
It's possible to use -not as a precursor to -starred and -tagged
|
|
||||||
to reverse their behaviour, however this requires some extra logic
|
|
||||||
to parse, and to ensure we still do not allow passing an empty -not
|
|
||||||
"""
|
|
||||||
|
|
||||||
parsed_args.exclude_starred = False
|
|
||||||
parsed_args.exclude_tagged = False
|
|
||||||
|
|
||||||
if "-not-starred" in "".join(args):
|
|
||||||
parsed_args.starred = False
|
|
||||||
parsed_args.exclude_starred = True
|
|
||||||
if "-not-tagged" in "".join(args):
|
|
||||||
parsed_args.tagged = False
|
|
||||||
parsed_args.exclude_tagged = True
|
|
||||||
if "-not" in args and not any(
|
|
||||||
[parsed_args.exclude_starred, parsed_args.exclude_tagged, parsed_args.excluded]
|
|
||||||
):
|
|
||||||
parser.error("argument -not: expected 1 argument")
|
|
||||||
|
|
||||||
return parsed_args
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(args: list[str] = []) -> argparse.Namespace:
|
|
||||||
"""
|
|
||||||
Argument parsing that is doable before the config is available.
|
|
||||||
Everything else goes into "text" for later parsing.
|
|
||||||
"""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
formatter_class=WrappingFormatter,
|
|
||||||
add_help=False,
|
|
||||||
description="Collect your thoughts and notes without leaving the command line",
|
|
||||||
epilog=textwrap.dedent(
|
|
||||||
"""
|
|
||||||
We gratefully thank all contributors!
|
|
||||||
Come see the whole list of code and financial contributors at https://github.com/jrnl-org/jrnl
|
|
||||||
And special thanks to Bad Lip Reading for the Yoda joke in the Writing section above :)"""
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
optional = parser.add_argument_group("Optional Arguments")
|
|
||||||
optional.add_argument(
|
|
||||||
"--debug",
|
|
||||||
dest="debug",
|
|
||||||
action="store_true",
|
|
||||||
help="Print information useful for troubleshooting",
|
|
||||||
)
|
|
||||||
|
|
||||||
standalone = parser.add_argument_group(
|
|
||||||
"Standalone Commands",
|
|
||||||
"These commands will exit after they complete. You may only run one at a time.",
|
|
||||||
)
|
|
||||||
standalone.add_argument("--help", action="help", help="Show this help message")
|
|
||||||
standalone.add_argument("-h", action="help", help=argparse.SUPPRESS)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--version",
|
|
||||||
action="store_const",
|
|
||||||
const=preconfig_version,
|
|
||||||
dest="preconfig_cmd",
|
|
||||||
help="Print version information",
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"-v",
|
|
||||||
action="store_const",
|
|
||||||
const=preconfig_version,
|
|
||||||
dest="preconfig_cmd",
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--diagnostic",
|
|
||||||
action="store_const",
|
|
||||||
const=preconfig_diagnostic,
|
|
||||||
dest="preconfig_cmd",
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--list",
|
|
||||||
action="store_const",
|
|
||||||
const=postconfig_list,
|
|
||||||
dest="postconfig_cmd",
|
|
||||||
help="""
|
|
||||||
List all configured journals.
|
|
||||||
|
|
||||||
Optional parameters:
|
|
||||||
|
|
||||||
--format [json or yaml]
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--ls",
|
|
||||||
action="store_const",
|
|
||||||
const=postconfig_list,
|
|
||||||
dest="postconfig_cmd",
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"-ls",
|
|
||||||
action="store_const",
|
|
||||||
const=lambda **kwargs: deprecated_cmd(
|
|
||||||
"-ls", "--list or --ls", callback=postconfig_list, **kwargs
|
|
||||||
),
|
|
||||||
dest="postconfig_cmd",
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--encrypt",
|
|
||||||
help="Encrypt selected journal with a password",
|
|
||||||
action="store_const",
|
|
||||||
metavar="TYPE",
|
|
||||||
const=postconfig_encrypt,
|
|
||||||
dest="postconfig_cmd",
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--decrypt",
|
|
||||||
help="Decrypt selected journal and store it in plain text",
|
|
||||||
action="store_const",
|
|
||||||
metavar="TYPE",
|
|
||||||
const=postconfig_decrypt,
|
|
||||||
dest="postconfig_cmd",
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--import",
|
|
||||||
action="store_const",
|
|
||||||
metavar="TYPE",
|
|
||||||
const=postconfig_import,
|
|
||||||
dest="postconfig_cmd",
|
|
||||||
help=f"""
|
|
||||||
Import entries from another journal.
|
|
||||||
|
|
||||||
Optional parameters:
|
|
||||||
|
|
||||||
--file FILENAME (default: uses stdin)
|
|
||||||
|
|
||||||
--format [{util.oxford_list(IMPORT_FORMATS)}] (default: jrnl)
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
standalone.add_argument(
|
|
||||||
"--file",
|
|
||||||
metavar="FILENAME",
|
|
||||||
dest="filename",
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
standalone.add_argument("-i", dest="filename", help=argparse.SUPPRESS)
|
|
||||||
|
|
||||||
compose_msg = """
|
|
||||||
To add a new entry into your journal, simply write it on the command line:
|
|
||||||
|
|
||||||
jrnl yesterday: I was walking and I found this big log.
|
|
||||||
|
|
||||||
The date and the following colon ("yesterday:") are optional. If you leave
|
|
||||||
them out, "now" will be used:
|
|
||||||
|
|
||||||
jrnl Then I rolled the log over.
|
|
||||||
|
|
||||||
Also, you can mark extra special entries ("star" them) with an asterisk:
|
|
||||||
|
|
||||||
jrnl *And underneath was a tiny little stick.
|
|
||||||
|
|
||||||
Please note that asterisks might be a special character in your shell, so you
|
|
||||||
might have to escape them. When in doubt about escaping, put quotes around
|
|
||||||
your entire entry:
|
|
||||||
|
|
||||||
jrnl "saturday at 2am: *Then I was like 'That log had a child!'" """
|
|
||||||
|
|
||||||
composing = parser.add_argument_group(
|
|
||||||
"Writing", textwrap.dedent(compose_msg).strip()
|
|
||||||
)
|
|
||||||
composing.add_argument("text", metavar="", nargs="*")
|
|
||||||
composing.add_argument(
|
|
||||||
"--template",
|
|
||||||
dest="template",
|
|
||||||
help="Path to template file. Can be a local path, absolute path, or a path relative to $XDG_DATA_HOME/jrnl/templates/",
|
|
||||||
)
|
|
||||||
|
|
||||||
read_msg = (
|
|
||||||
"To find entries from your journal, use any combination of the below filters."
|
|
||||||
)
|
|
||||||
reading = parser.add_argument_group("Searching", textwrap.dedent(read_msg))
|
|
||||||
reading.add_argument(
|
|
||||||
"-on", dest="on_date", metavar="DATE", help="Show entries on this date"
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-today-in-history",
|
|
||||||
dest="today_in_history",
|
|
||||||
action="store_true",
|
|
||||||
help="Show entries of today over the years",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-month",
|
|
||||||
dest="month",
|
|
||||||
metavar="DATE",
|
|
||||||
help="Show entries on this month of any year",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-day",
|
|
||||||
dest="day",
|
|
||||||
metavar="DATE",
|
|
||||||
help="Show entries on this day of any month",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-year",
|
|
||||||
dest="year",
|
|
||||||
metavar="DATE",
|
|
||||||
help="Show entries of a specific year",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-from",
|
|
||||||
dest="start_date",
|
|
||||||
metavar="DATE",
|
|
||||||
help="Show entries after, or on, this date",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-to",
|
|
||||||
dest="end_date",
|
|
||||||
metavar="DATE",
|
|
||||||
help="Show entries before, or on, this date (alias: -until)",
|
|
||||||
)
|
|
||||||
reading.add_argument("-until", dest="end_date", help=argparse.SUPPRESS)
|
|
||||||
reading.add_argument(
|
|
||||||
"-contains",
|
|
||||||
dest="contains",
|
|
||||||
metavar="TEXT",
|
|
||||||
help="Show entries containing specific text (put quotes around text with spaces)",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-and",
|
|
||||||
dest="strict",
|
|
||||||
action="store_true",
|
|
||||||
help='Show only entries that match all conditions, like saying "x AND y" (default: OR)',
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-starred",
|
|
||||||
dest="starred",
|
|
||||||
action="store_true",
|
|
||||||
help="Show only starred entries (marked with *)",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-tagged",
|
|
||||||
dest="tagged",
|
|
||||||
action="store_true",
|
|
||||||
help="Show only entries that have at least one tag",
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-n",
|
|
||||||
dest="limit",
|
|
||||||
default=None,
|
|
||||||
metavar="NUMBER",
|
|
||||||
help="Show a maximum of NUMBER entries (note: '-n 3' and '-3' have the same effect)",
|
|
||||||
nargs="?",
|
|
||||||
type=int,
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
|
||||||
"-not",
|
|
||||||
dest="excluded",
|
|
||||||
nargs="?",
|
|
||||||
default=[],
|
|
||||||
metavar="TAG/FLAG",
|
|
||||||
action=IgnoreNoneAppendAction,
|
|
||||||
help=(
|
|
||||||
"If passed a string, will exclude entries with that tag. "
|
|
||||||
"Can be also used before -starred or -tagged flags, to exclude "
|
|
||||||
"starred or tagged entries respectively."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
search_options_msg = """ These help you do various tasks with the selected entries from your search.
|
|
||||||
If used on their own (with no search), they will act on your entire journal"""
|
|
||||||
exporting = parser.add_argument_group(
|
|
||||||
"Searching Options", textwrap.dedent(search_options_msg)
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"--edit",
|
|
||||||
dest="edit",
|
|
||||||
help="Opens the selected entries in your configured editor",
|
|
||||||
action="store_true",
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"--delete",
|
|
||||||
dest="delete",
|
|
||||||
action="store_true",
|
|
||||||
help="Interactively deletes selected entries",
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"--change-time",
|
|
||||||
dest="change_time",
|
|
||||||
nargs="?",
|
|
||||||
metavar="DATE",
|
|
||||||
const="now",
|
|
||||||
help="Change timestamp for selected entries (default: now)",
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"--format",
|
|
||||||
metavar="TYPE",
|
|
||||||
dest="export",
|
|
||||||
choices=EXPORT_FORMATS,
|
|
||||||
help=f"""
|
|
||||||
Display selected entries in an alternate format.
|
|
||||||
|
|
||||||
TYPE can be: {util.oxford_list(EXPORT_FORMATS)}.
|
|
||||||
|
|
||||||
Optional parameters:
|
|
||||||
|
|
||||||
--file FILENAME Write output to file instead of stdout
|
|
||||||
""",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"--export",
|
|
||||||
metavar="TYPE",
|
|
||||||
dest="export",
|
|
||||||
choices=EXPORT_FORMATS,
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"--tags",
|
|
||||||
dest="tags",
|
|
||||||
action="store_true",
|
|
||||||
help="Alias for '--format tags'. Returns a list of all tags and number of occurrences",
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"--short",
|
|
||||||
dest="short",
|
|
||||||
action="store_true",
|
|
||||||
help="Show only titles or line containing the search tags",
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"-s",
|
|
||||||
dest="short",
|
|
||||||
action="store_true",
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
exporting.add_argument(
|
|
||||||
"-o",
|
|
||||||
dest="filename",
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
|
|
||||||
config_overrides = parser.add_argument_group(
|
|
||||||
"Config file override",
|
|
||||||
textwrap.dedent("Apply a one-off override of the config file option"),
|
|
||||||
)
|
|
||||||
config_overrides.add_argument(
|
|
||||||
"--config-override",
|
|
||||||
dest="config_override",
|
|
||||||
action="append",
|
|
||||||
type=str,
|
|
||||||
nargs=2,
|
|
||||||
default=[],
|
|
||||||
metavar="CONFIG_KV_PAIR",
|
|
||||||
help="""
|
|
||||||
Override configured key-value pair with CONFIG_KV_PAIR for this command invocation only.
|
|
||||||
|
|
||||||
Examples: \n
|
|
||||||
\t - Use a different editor for this jrnl entry, call: \n
|
|
||||||
\t jrnl --config-override editor "nano" \n
|
|
||||||
\t - Override color selections\n
|
|
||||||
\t jrnl --config-override colors.body blue --config-override colors.title green
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
config_overrides.add_argument(
|
|
||||||
"--co",
|
|
||||||
dest="config_override",
|
|
||||||
action="append",
|
|
||||||
type=str,
|
|
||||||
nargs=2,
|
|
||||||
default=[],
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
|
|
||||||
alternate_config = parser.add_argument_group(
|
|
||||||
"Specifies alternate config to be used",
|
|
||||||
textwrap.dedent("Applies alternate config for current session"),
|
|
||||||
)
|
|
||||||
|
|
||||||
alternate_config.add_argument(
|
|
||||||
"--config-file",
|
|
||||||
dest="config_file_path",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help="""
|
|
||||||
Overrides default (created when first installed) config file for this command only.
|
|
||||||
|
|
||||||
Examples: \n
|
|
||||||
\t - Use a work config file for this jrnl entry, call: \n
|
|
||||||
\t jrnl --config-file /home/user1/work_config.yaml
|
|
||||||
\t - Use a personal config file stored on a thumb drive: \n
|
|
||||||
\t jrnl --config-file /media/user1/my-thumb-drive/personal_config.yaml
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
alternate_config.add_argument(
|
|
||||||
"--cf", dest="config_file_path", type=str, default="", help=argparse.SUPPRESS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle '-123' as a shortcut for '-n 123'
|
|
||||||
num = re.compile(r"^-(\d+)$")
|
|
||||||
args = [num.sub(r"-n \1", arg) for arg in args]
|
|
||||||
parsed_args = parser.parse_intermixed_args(args)
|
|
||||||
parsed_args = parse_not_arg(args, parsed_args, parser)
|
|
||||||
|
|
||||||
return parsed_args
|
|
|
@ -1,83 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import re
|
|
||||||
from string import punctuation
|
|
||||||
from string import whitespace
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
from jrnl.os_compat import on_windows
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
|
|
||||||
if on_windows():
|
|
||||||
colorama.init()
|
|
||||||
|
|
||||||
|
|
||||||
def colorize(string: str, color: str, bold: bool = False) -> str:
|
|
||||||
"""Returns the string colored with colorama.Fore.color. If the color set by
|
|
||||||
the user is "NONE" or the color doesn't exist in the colorama.Fore attributes,
|
|
||||||
it returns the string without any modification."""
|
|
||||||
color_escape = getattr(colorama.Fore, color.upper(), None)
|
|
||||||
if not color_escape:
|
|
||||||
return string
|
|
||||||
elif not bold:
|
|
||||||
return color_escape + string + colorama.Fore.RESET
|
|
||||||
else:
|
|
||||||
return colorama.Style.BRIGHT + color_escape + string + colorama.Style.RESET_ALL
|
|
||||||
|
|
||||||
|
|
||||||
def highlight_tags_with_background_color(
|
|
||||||
entry: "Entry", text: str, color: str, is_title: bool = False
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Takes a string and colorizes the tags in it based upon the config value for
|
|
||||||
color.tags, while colorizing the rest of the text based on `color`.
|
|
||||||
:param entry: Entry object, for access to journal config
|
|
||||||
:param text: Text to be colorized
|
|
||||||
:param color: Color for non-tag text, passed to colorize()
|
|
||||||
:param is_title: Boolean flag indicating if the text is a title or not
|
|
||||||
:return: Colorized str
|
|
||||||
"""
|
|
||||||
|
|
||||||
def colorized_text_generator(fragments):
|
|
||||||
"""Efficiently generate colorized tags / text from text fragments.
|
|
||||||
Taken from @shobrook. Thanks, buddy :)
|
|
||||||
:param fragments: List of strings representing parts of entry (tag or word).
|
|
||||||
:rtype: List of tuples
|
|
||||||
:returns [(colorized_str, original_str)]"""
|
|
||||||
for part in fragments:
|
|
||||||
if part and part[0] not in config["tagsymbols"]:
|
|
||||||
yield colorize(part, color, bold=is_title), part
|
|
||||||
elif part:
|
|
||||||
yield colorize(part, config["colors"]["tags"], bold=True), part
|
|
||||||
|
|
||||||
config = entry.journal.config
|
|
||||||
if config["highlight"]: # highlight tags
|
|
||||||
text_fragments = re.split(entry.tag_regex(config["tagsymbols"]), text)
|
|
||||||
|
|
||||||
# Colorizing tags inside of other blocks of text
|
|
||||||
final_text = ""
|
|
||||||
previous_piece = ""
|
|
||||||
for colorized_piece, piece in colorized_text_generator(text_fragments):
|
|
||||||
# If this piece is entirely punctuation or whitespace or the start
|
|
||||||
# of a line or the previous piece was a tag or this piece is a tag,
|
|
||||||
# then add it to the final text without a leading space.
|
|
||||||
if (
|
|
||||||
all(char in punctuation + whitespace for char in piece)
|
|
||||||
or previous_piece.endswith("\n")
|
|
||||||
or (previous_piece and previous_piece[0] in config["tagsymbols"])
|
|
||||||
or piece[0] in config["tagsymbols"]
|
|
||||||
):
|
|
||||||
final_text += colorized_piece
|
|
||||||
else:
|
|
||||||
# Otherwise add a leading space and then append the piece.
|
|
||||||
final_text += " " + colorized_piece
|
|
||||||
|
|
||||||
previous_piece = piece
|
|
||||||
return final_text.lstrip()
|
|
||||||
else:
|
|
||||||
return text
|
|
172
jrnl/commands.py
172
jrnl/commands.py
|
@ -1,172 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
"""
|
|
||||||
Functions in this file are standalone commands. All standalone commands are split into
|
|
||||||
two categories depending on whether they require the config to be loaded to be able to
|
|
||||||
run.
|
|
||||||
|
|
||||||
1. "preconfig" commands don't require the config at all, and can be run before the
|
|
||||||
config has been loaded.
|
|
||||||
2. "postconfig" commands require to config to have already been loaded, parsed, and
|
|
||||||
scoped before they can be run.
|
|
||||||
|
|
||||||
Also, please note that all (non-builtin) imports should be scoped to each function to
|
|
||||||
avoid any possible overhead for these standalone commands.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import platform
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from jrnl.config import cmd_requires_valid_journal_name
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
|
|
||||||
|
|
||||||
def preconfig_diagnostic(_) -> None:
|
|
||||||
from jrnl import __title__
|
|
||||||
from jrnl import __version__
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"{__title__}: {__version__}\n"
|
|
||||||
f"Python: {sys.version}\n"
|
|
||||||
f"OS: {platform.system()} {platform.release()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def preconfig_version(_) -> None:
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
from jrnl import __title__
|
|
||||||
from jrnl import __version__
|
|
||||||
|
|
||||||
output = f"""
|
|
||||||
{__title__} {__version__}
|
|
||||||
|
|
||||||
Copyright © 2012-2023 jrnl contributors
|
|
||||||
|
|
||||||
This is free software, and you are welcome to redistribute it under certain
|
|
||||||
conditions; for details, see: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
output = textwrap.dedent(output).strip()
|
|
||||||
|
|
||||||
print(output)
|
|
||||||
|
|
||||||
|
|
||||||
def postconfig_list(args: argparse.Namespace, config: dict, **_) -> int:
|
|
||||||
from jrnl.output import list_journals
|
|
||||||
|
|
||||||
print(list_journals(config, args.export))
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
@cmd_requires_valid_journal_name
|
|
||||||
def postconfig_import(args: argparse.Namespace, config: dict, **_) -> int:
|
|
||||||
from jrnl.journals import open_journal
|
|
||||||
from jrnl.plugins import get_importer
|
|
||||||
|
|
||||||
# Requires opening the journal
|
|
||||||
journal = open_journal(args.journal_name, config)
|
|
||||||
|
|
||||||
format = args.export if args.export else "jrnl"
|
|
||||||
get_importer(format).import_(journal, args.filename)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
@cmd_requires_valid_journal_name
|
|
||||||
def postconfig_encrypt(
|
|
||||||
args: argparse.Namespace, config: dict, original_config: dict
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Encrypt a journal in place, or optionally to a new file
|
|
||||||
"""
|
|
||||||
from jrnl.config import update_config
|
|
||||||
from jrnl.install import save_config
|
|
||||||
from jrnl.journals import open_journal
|
|
||||||
|
|
||||||
# Open the journal
|
|
||||||
journal = open_journal(args.journal_name, config)
|
|
||||||
|
|
||||||
if hasattr(journal, "can_be_encrypted") and not journal.can_be_encrypted:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.CannotEncryptJournalType,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{
|
|
||||||
"journal_name": args.journal_name,
|
|
||||||
"journal_type": journal.__class__.__name__,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# If journal is encrypted, create new password
|
|
||||||
logging.debug("Clearing encryption method...")
|
|
||||||
|
|
||||||
if journal.config["encrypt"] is True:
|
|
||||||
logging.debug("Journal already encrypted. Re-encrypting...")
|
|
||||||
print(f"Journal {journal.name} is already encrypted. Create a new password.")
|
|
||||||
journal.encryption_method.clear()
|
|
||||||
else:
|
|
||||||
journal.config["encrypt"] = True
|
|
||||||
journal.encryption_method = None
|
|
||||||
|
|
||||||
journal.write(args.filename)
|
|
||||||
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.JournalEncryptedTo,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{"path": args.filename or journal.config["journal"]},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update the config, if we encrypted in place
|
|
||||||
if not args.filename:
|
|
||||||
update_config(
|
|
||||||
original_config, {"encrypt": True}, args.journal_name, force_local=True
|
|
||||||
)
|
|
||||||
save_config(original_config)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
@cmd_requires_valid_journal_name
|
|
||||||
def postconfig_decrypt(
|
|
||||||
args: argparse.Namespace, config: dict, original_config: dict
|
|
||||||
) -> int:
|
|
||||||
"""Decrypts into new file. If filename is not set, we encrypt the journal file itself."""
|
|
||||||
from jrnl.config import update_config
|
|
||||||
from jrnl.install import save_config
|
|
||||||
from jrnl.journals import open_journal
|
|
||||||
|
|
||||||
journal = open_journal(args.journal_name, config)
|
|
||||||
|
|
||||||
logging.debug("Clearing encryption method...")
|
|
||||||
journal.config["encrypt"] = False
|
|
||||||
journal.encryption_method = None
|
|
||||||
|
|
||||||
journal.write(args.filename)
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.JournalDecryptedTo,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{"path": args.filename or journal.config["journal"]},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update the config, if we decrypted in place
|
|
||||||
if not args.filename:
|
|
||||||
update_config(
|
|
||||||
original_config, {"encrypt": False}, args.journal_name, force_local=True
|
|
||||||
)
|
|
||||||
save_config(original_config)
|
|
||||||
|
|
||||||
return 0
|
|
228
jrnl/config.py
228
jrnl/config.py
|
@ -1,228 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
from rich.pretty import pretty_repr
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
from ruamel.yaml import constructor
|
|
||||||
|
|
||||||
from jrnl import __version__
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import list_journals
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.path import get_config_path
|
|
||||||
from jrnl.path import get_default_journal_path
|
|
||||||
|
|
||||||
# Constants
|
|
||||||
DEFAULT_JOURNAL_KEY = "default"
|
|
||||||
|
|
||||||
YAML_SEPARATOR = ": "
|
|
||||||
YAML_FILE_ENCODING = "utf-8"
|
|
||||||
|
|
||||||
|
|
||||||
def make_yaml_valid_dict(input: list) -> dict:
|
|
||||||
"""
|
|
||||||
|
|
||||||
Convert a two-element list of configuration key-value pair into a flat dict.
|
|
||||||
|
|
||||||
The dict is created through the yaml loader, with the assumption that
|
|
||||||
"input[0]: input[1]" is valid yaml.
|
|
||||||
|
|
||||||
:param input: list of configuration keys in dot-notation and their respective values.
|
|
||||||
:type input: list
|
|
||||||
:return: A single level dict of the configuration keys in dot-notation and their respective desired values
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert len(input) == 2
|
|
||||||
|
|
||||||
# yaml compatible strings are of the form Key:Value
|
|
||||||
yamlstr = YAML_SEPARATOR.join(input)
|
|
||||||
|
|
||||||
runtime_modifications = YAML(typ="safe").load(yamlstr)
|
|
||||||
|
|
||||||
return runtime_modifications
|
|
||||||
|
|
||||||
|
|
||||||
def save_config(config: dict, alt_config_path: str | None = None) -> None:
|
|
||||||
"""Supply alt_config_path if using an alternate config through --config-file."""
|
|
||||||
config["version"] = __version__
|
|
||||||
|
|
||||||
yaml = YAML(typ="safe")
|
|
||||||
yaml.default_flow_style = False # prevents collapsing of tree structure
|
|
||||||
|
|
||||||
with open(
|
|
||||||
alt_config_path if alt_config_path else get_config_path(),
|
|
||||||
"w",
|
|
||||||
encoding=YAML_FILE_ENCODING,
|
|
||||||
) as f:
|
|
||||||
yaml.dump(config, f)
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_config() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"version": __version__,
|
|
||||||
"journals": {"default": {"journal": get_default_journal_path()}},
|
|
||||||
"editor": os.getenv("VISUAL") or os.getenv("EDITOR") or "",
|
|
||||||
"encrypt": False,
|
|
||||||
"template": False,
|
|
||||||
"default_hour": 9,
|
|
||||||
"default_minute": 0,
|
|
||||||
"timeformat": "%F %r",
|
|
||||||
"tagsymbols": "#@",
|
|
||||||
"highlight": True,
|
|
||||||
"linewrap": 79,
|
|
||||||
"indent_character": "|",
|
|
||||||
"colors": {
|
|
||||||
"body": "none",
|
|
||||||
"date": "none",
|
|
||||||
"tags": "none",
|
|
||||||
"title": "none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_colors() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"body": "none",
|
|
||||||
"date": "black",
|
|
||||||
"tags": "yellow",
|
|
||||||
"title": "cyan",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def scope_config(config: dict, journal_name: str) -> dict:
|
|
||||||
if journal_name not in config["journals"]:
|
|
||||||
return config
|
|
||||||
config = config.copy()
|
|
||||||
journal_conf = config["journals"].get(journal_name)
|
|
||||||
if type(journal_conf) is dict:
|
|
||||||
# We can override the default config on a by-journal basis
|
|
||||||
logging.debug(
|
|
||||||
"Updating configuration with specific journal overrides:\n%s",
|
|
||||||
pretty_repr(journal_conf),
|
|
||||||
)
|
|
||||||
config.update(journal_conf)
|
|
||||||
else:
|
|
||||||
# But also just give them a string to point to the journal file
|
|
||||||
config["journal"] = journal_conf
|
|
||||||
|
|
||||||
logging.debug("Scoped config:\n%s", pretty_repr(config))
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def verify_config_colors(config: dict) -> bool:
|
|
||||||
"""
|
|
||||||
Ensures the keys set for colors are valid colorama.Fore attributes, or "None"
|
|
||||||
:return: True if all keys are set correctly, False otherwise
|
|
||||||
"""
|
|
||||||
all_valid_colors = True
|
|
||||||
for key, color in config["colors"].items():
|
|
||||||
upper_color = color.upper()
|
|
||||||
if upper_color == "NONE":
|
|
||||||
continue
|
|
||||||
if not getattr(colorama.Fore, upper_color, None):
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.InvalidColor,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{
|
|
||||||
"key": key,
|
|
||||||
"color": color,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
all_valid_colors = False
|
|
||||||
return all_valid_colors
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: str) -> dict:
|
|
||||||
"""Tries to load a config file from YAML."""
|
|
||||||
try:
|
|
||||||
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
|
|
||||||
yaml = YAML(typ="safe")
|
|
||||||
yaml.allow_duplicate_keys = False
|
|
||||||
return yaml.load(f)
|
|
||||||
except constructor.DuplicateKeyError as e:
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.ConfigDoubleKeys,
|
|
||||||
MsgStyle.WARNING,
|
|
||||||
{
|
|
||||||
"error_message": e,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
|
|
||||||
yaml = YAML(typ="safe")
|
|
||||||
yaml.allow_duplicate_keys = True
|
|
||||||
return yaml.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def is_config_json(config_path: str) -> bool:
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
config_file = f.read()
|
|
||||||
return config_file.strip().startswith("{")
|
|
||||||
|
|
||||||
|
|
||||||
def update_config(
|
|
||||||
config: dict, new_config: dict, scope: str | None, force_local: bool = False
|
|
||||||
) -> None:
|
|
||||||
"""Updates a config dict with new values - either global if scope is None
|
|
||||||
or config['journals'][scope] is just a string pointing to a journal file,
|
|
||||||
or within the scope"""
|
|
||||||
if scope and type(config["journals"][scope]) is dict: # Update to journal specific
|
|
||||||
config["journals"][scope].update(new_config)
|
|
||||||
elif scope and force_local: # Convert to dict
|
|
||||||
config["journals"][scope] = {"journal": config["journals"][scope]}
|
|
||||||
config["journals"][scope].update(new_config)
|
|
||||||
else:
|
|
||||||
config.update(new_config)
|
|
||||||
|
|
||||||
|
|
||||||
def get_journal_name(args: argparse.Namespace, config: dict) -> argparse.Namespace:
|
|
||||||
args.journal_name = DEFAULT_JOURNAL_KEY
|
|
||||||
|
|
||||||
# The first arg might be a journal name
|
|
||||||
if args.text:
|
|
||||||
potential_journal_name = args.text[0]
|
|
||||||
if potential_journal_name[-1] == ":":
|
|
||||||
potential_journal_name = potential_journal_name[0:-1]
|
|
||||||
|
|
||||||
if potential_journal_name in config["journals"]:
|
|
||||||
args.journal_name = potential_journal_name
|
|
||||||
args.text = args.text[1:]
|
|
||||||
|
|
||||||
logging.debug("Using journal name: %s", args.journal_name)
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_requires_valid_journal_name(func: Callable) -> Callable:
|
|
||||||
def wrapper(args: argparse.Namespace, config: dict, original_config: dict):
|
|
||||||
validate_journal_name(args.journal_name, config)
|
|
||||||
func(args=args, config=config, original_config=original_config)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def validate_journal_name(journal_name: str, config: dict) -> None:
|
|
||||||
if journal_name not in config["journals"]:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.NoNamedJournal,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{
|
|
||||||
"journal_name": journal_name,
|
|
||||||
"journals": list_journals(config),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
|
@ -1,453 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl import install
|
|
||||||
from jrnl import plugins
|
|
||||||
from jrnl import time
|
|
||||||
from jrnl.config import DEFAULT_JOURNAL_KEY
|
|
||||||
from jrnl.config import get_config_path
|
|
||||||
from jrnl.config import get_journal_name
|
|
||||||
from jrnl.config import scope_config
|
|
||||||
from jrnl.editor import get_text_from_editor
|
|
||||||
from jrnl.editor import get_text_from_stdin
|
|
||||||
from jrnl.editor import read_template_file
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.journals import open_journal
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.output import print_msgs
|
|
||||||
from jrnl.override import apply_overrides
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
def run(args: "Namespace"):
|
|
||||||
"""
|
|
||||||
Flow:
|
|
||||||
1. Run standalone command if it doesn't require config (help, version, etc), then exit
|
|
||||||
2. Load config
|
|
||||||
3. Run standalone command if it does require config (encrypt, decrypt, etc), then exit
|
|
||||||
4. Load specified journal
|
|
||||||
5. Start append mode, or search mode
|
|
||||||
6. Perform actions with results from search mode (if needed)
|
|
||||||
7. Profit
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Run command if possible before config is available
|
|
||||||
if callable(args.preconfig_cmd):
|
|
||||||
return args.preconfig_cmd(args)
|
|
||||||
|
|
||||||
# Load the config, and extract journal name
|
|
||||||
config = install.load_or_install_jrnl(args.config_file_path)
|
|
||||||
original_config = config.copy()
|
|
||||||
|
|
||||||
# Apply config overrides
|
|
||||||
config = apply_overrides(args, config)
|
|
||||||
|
|
||||||
args = get_journal_name(args, config)
|
|
||||||
config = scope_config(config, args.journal_name)
|
|
||||||
|
|
||||||
# Run post-config command now that config is ready
|
|
||||||
if callable(args.postconfig_cmd):
|
|
||||||
return args.postconfig_cmd(
|
|
||||||
args=args, config=config, original_config=original_config
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- All the standalone commands are now done --- #
|
|
||||||
|
|
||||||
# Get the journal we're going to be working with
|
|
||||||
journal = open_journal(args.journal_name, config)
|
|
||||||
|
|
||||||
kwargs = {
|
|
||||||
"args": args,
|
|
||||||
"config": config,
|
|
||||||
"journal": journal,
|
|
||||||
"old_entries": journal.entries,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _is_append_mode(**kwargs):
|
|
||||||
append_mode(**kwargs)
|
|
||||||
return
|
|
||||||
|
|
||||||
# If not append mode, then we're in search mode (only 2 modes exist)
|
|
||||||
search_mode(**kwargs)
|
|
||||||
entries_found_count = len(journal)
|
|
||||||
_print_entries_found_count(entries_found_count, args)
|
|
||||||
|
|
||||||
# Actions
|
|
||||||
_perform_actions_on_search_results(**kwargs)
|
|
||||||
|
|
||||||
if entries_found_count != 0 and _has_action_args(args):
|
|
||||||
_print_changed_counts(journal)
|
|
||||||
else:
|
|
||||||
# display only occurs if no other action occurs
|
|
||||||
_display_search_results(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _perform_actions_on_search_results(**kwargs):
|
|
||||||
args = kwargs["args"]
|
|
||||||
|
|
||||||
# Perform actions (if needed)
|
|
||||||
if args.change_time:
|
|
||||||
_change_time_search_results(**kwargs)
|
|
||||||
|
|
||||||
if args.delete:
|
|
||||||
_delete_search_results(**kwargs)
|
|
||||||
|
|
||||||
# open results in editor (if `--edit` was used)
|
|
||||||
if args.edit:
|
|
||||||
_edit_search_results(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_append_mode(args: "Namespace", config: dict, **kwargs) -> bool:
|
|
||||||
"""Determines if we are in append mode (as opposed to search mode)"""
|
|
||||||
# Are any search filters present? If so, then search mode.
|
|
||||||
append_mode = (
|
|
||||||
not _has_search_args(args)
|
|
||||||
and not _has_action_args(args)
|
|
||||||
and not _has_display_args(args)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Might be writing and want to move to editor part of the way through
|
|
||||||
if args.edit and args.text:
|
|
||||||
append_mode = True
|
|
||||||
|
|
||||||
# If the text is entirely tags, then we are also searching (not writing)
|
|
||||||
if append_mode and args.text and _has_only_tags(config["tagsymbols"], args.text):
|
|
||||||
append_mode = False
|
|
||||||
|
|
||||||
return append_mode
|
|
||||||
|
|
||||||
|
|
||||||
def append_mode(args: "Namespace", config: dict, journal: "Journal", **kwargs) -> None:
|
|
||||||
"""
|
|
||||||
Gets input from the user to write to the journal
|
|
||||||
0. Check for a template passed as an argument, or in the global config
|
|
||||||
1. Check for input from cli
|
|
||||||
2. Check input being piped in
|
|
||||||
3. Open editor if configured (prepopulated with template if available)
|
|
||||||
4. Use stdin.read as last resort
|
|
||||||
6. Write any found text to journal, or exit
|
|
||||||
"""
|
|
||||||
logging.debug("Append mode: starting")
|
|
||||||
|
|
||||||
template_text = _get_template(args, config)
|
|
||||||
|
|
||||||
if args.text:
|
|
||||||
logging.debug(f"Append mode: cli text detected: {args.text}")
|
|
||||||
raw = " ".join(args.text).strip()
|
|
||||||
if args.edit:
|
|
||||||
raw = _write_in_editor(config, raw)
|
|
||||||
elif not sys.stdin.isatty():
|
|
||||||
logging.debug("Append mode: receiving piped text")
|
|
||||||
raw = sys.stdin.read()
|
|
||||||
else:
|
|
||||||
raw = _write_in_editor(config, template_text)
|
|
||||||
|
|
||||||
if template_text is not None and raw == template_text:
|
|
||||||
logging.error("Append mode: raw text was the same as the template")
|
|
||||||
raise JrnlException(Message(MsgText.NoChangesToTemplate, MsgStyle.NORMAL))
|
|
||||||
|
|
||||||
if not raw or raw.isspace():
|
|
||||||
logging.error("Append mode: couldn't get raw text or entry was empty")
|
|
||||||
raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.NORMAL))
|
|
||||||
|
|
||||||
logging.debug(
|
|
||||||
f"Append mode: appending raw text to journal '{args.journal_name}': {raw}"
|
|
||||||
)
|
|
||||||
journal.new_entry(raw)
|
|
||||||
if args.journal_name != DEFAULT_JOURNAL_KEY:
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.JournalEntryAdded,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{"journal_name": args.journal_name},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
journal.write()
|
|
||||||
logging.debug("Append mode: completed journal.write()")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_template(args, config) -> str:
|
|
||||||
# Read template file and pass as raw text into the composer
|
|
||||||
logging.debug(
|
|
||||||
f"Get template:\n--template: {args.template}\nfrom config: {config.get('template')}"
|
|
||||||
)
|
|
||||||
template_path = args.template or config.get("template")
|
|
||||||
|
|
||||||
template_text = None
|
|
||||||
|
|
||||||
if template_path:
|
|
||||||
template_text = read_template_file(template_path)
|
|
||||||
|
|
||||||
return template_text
|
|
||||||
|
|
||||||
|
|
||||||
def search_mode(args: "Namespace", journal: "Journal", **kwargs) -> None:
|
|
||||||
"""
|
|
||||||
Search for entries in a journal, and return the
|
|
||||||
results. If no search args, then return all results
|
|
||||||
"""
|
|
||||||
logging.debug("Search mode: starting")
|
|
||||||
|
|
||||||
# If no search args, then return all results (don't filter anything)
|
|
||||||
if not _has_search_args(args) and not _has_display_args(args) and not args.text:
|
|
||||||
logging.debug("Search mode: has no search args")
|
|
||||||
return
|
|
||||||
|
|
||||||
logging.debug("Search mode: has search args")
|
|
||||||
_filter_journal_entries(args, journal)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_in_editor(config: dict, prepopulated_text: str | None = None) -> str:
|
|
||||||
if config["editor"]:
|
|
||||||
logging.debug("Append mode: opening editor")
|
|
||||||
raw = get_text_from_editor(config, prepopulated_text)
|
|
||||||
else:
|
|
||||||
raw = get_text_from_stdin()
|
|
||||||
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_journal_entries(args: "Namespace", journal: "Journal", **kwargs) -> None:
|
|
||||||
"""Filter journal entries in-place based upon search args"""
|
|
||||||
if args.on_date:
|
|
||||||
args.start_date = args.end_date = args.on_date
|
|
||||||
|
|
||||||
if args.today_in_history:
|
|
||||||
now = time.parse("now")
|
|
||||||
args.day = now.day
|
|
||||||
args.month = now.month
|
|
||||||
|
|
||||||
journal.filter(
|
|
||||||
tags=args.text,
|
|
||||||
month=args.month,
|
|
||||||
day=args.day,
|
|
||||||
year=args.year,
|
|
||||||
start_date=args.start_date,
|
|
||||||
end_date=args.end_date,
|
|
||||||
strict=args.strict,
|
|
||||||
starred=args.starred,
|
|
||||||
tagged=args.tagged,
|
|
||||||
exclude=args.excluded,
|
|
||||||
exclude_starred=args.exclude_starred,
|
|
||||||
exclude_tagged=args.exclude_tagged,
|
|
||||||
contains=args.contains,
|
|
||||||
)
|
|
||||||
journal.limit(args.limit)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_entries_found_count(count: int, args: "Namespace") -> None:
|
|
||||||
logging.debug(f"count: {count}")
|
|
||||||
if count == 0:
|
|
||||||
if args.edit or args.change_time:
|
|
||||||
print_msg(Message(MsgText.NothingToModify, MsgStyle.WARNING))
|
|
||||||
elif args.delete:
|
|
||||||
print_msg(Message(MsgText.NothingToDelete, MsgStyle.WARNING))
|
|
||||||
else:
|
|
||||||
print_msg(Message(MsgText.NoEntriesFound, MsgStyle.NORMAL))
|
|
||||||
return
|
|
||||||
elif args.limit and args.limit == count:
|
|
||||||
# Don't show count if the user expects a limited number of results
|
|
||||||
logging.debug("args.limit is true-ish")
|
|
||||||
return
|
|
||||||
|
|
||||||
logging.debug("Printing general summary")
|
|
||||||
my_msg = (
|
|
||||||
MsgText.EntryFoundCountSingular if count == 1 else MsgText.EntryFoundCountPlural
|
|
||||||
)
|
|
||||||
print_msg(Message(my_msg, MsgStyle.NORMAL, {"num": count}))
|
|
||||||
|
|
||||||
|
|
||||||
def _other_entries(journal: "Journal", entries: list["Entry"]) -> list["Entry"]:
|
|
||||||
"""Find entries that are not in journal"""
|
|
||||||
return [e for e in entries if e not in journal.entries]
|
|
||||||
|
|
||||||
|
|
||||||
def _edit_search_results(
|
|
||||||
config: dict, journal: "Journal", old_entries: list["Entry"], **kwargs
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
1. Send the given journal entries to the user-configured editor
|
|
||||||
2. Print out stats on any modifications to journal
|
|
||||||
3. Write modifications to journal
|
|
||||||
"""
|
|
||||||
if not config["editor"]:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.EditorNotConfigured,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{"config_file": get_config_path()},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# separate entries we are not editing
|
|
||||||
other_entries = _other_entries(journal, old_entries)
|
|
||||||
|
|
||||||
# Send user to the editor
|
|
||||||
try:
|
|
||||||
edited = get_text_from_editor(config, journal.editable_str())
|
|
||||||
except JrnlException as e:
|
|
||||||
if e.has_message_text(MsgText.NoTextReceived):
|
|
||||||
raise JrnlException(
|
|
||||||
Message(MsgText.NoEditsReceivedJournalNotDeleted, MsgStyle.WARNING)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
journal.parse_editable_str(edited)
|
|
||||||
|
|
||||||
# Put back entries we separated earlier, sort, and write the journal
|
|
||||||
journal.entries += other_entries
|
|
||||||
journal.sort()
|
|
||||||
journal.write()
|
|
||||||
|
|
||||||
|
|
||||||
def _print_changed_counts(journal: "Journal", **kwargs) -> None:
|
|
||||||
stats = journal.get_change_counts()
|
|
||||||
msgs = []
|
|
||||||
|
|
||||||
if stats["added"] > 0:
|
|
||||||
my_msg = (
|
|
||||||
MsgText.JournalCountAddedSingular
|
|
||||||
if stats["added"] == 1
|
|
||||||
else MsgText.JournalCountAddedPlural
|
|
||||||
)
|
|
||||||
msgs.append(Message(my_msg, MsgStyle.NORMAL, {"num": stats["added"]}))
|
|
||||||
|
|
||||||
if stats["deleted"] > 0:
|
|
||||||
my_msg = (
|
|
||||||
MsgText.JournalCountDeletedSingular
|
|
||||||
if stats["deleted"] == 1
|
|
||||||
else MsgText.JournalCountDeletedPlural
|
|
||||||
)
|
|
||||||
msgs.append(Message(my_msg, MsgStyle.NORMAL, {"num": stats["deleted"]}))
|
|
||||||
|
|
||||||
if stats["modified"] > 0:
|
|
||||||
my_msg = (
|
|
||||||
MsgText.JournalCountModifiedSingular
|
|
||||||
if stats["modified"] == 1
|
|
||||||
else MsgText.JournalCountModifiedPlural
|
|
||||||
)
|
|
||||||
msgs.append(Message(my_msg, MsgStyle.NORMAL, {"num": stats["modified"]}))
|
|
||||||
|
|
||||||
if not msgs:
|
|
||||||
msgs.append(Message(MsgText.NoEditsReceived, MsgStyle.NORMAL))
|
|
||||||
|
|
||||||
print_msgs(msgs)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_predit_stats(journal: "Journal") -> dict[str, int]:
|
|
||||||
return {"count": len(journal)}
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_search_results(
|
|
||||||
journal: "Journal", old_entries: list["Entry"], **kwargs
|
|
||||||
) -> None:
|
|
||||||
entries_to_delete = journal.prompt_action_entries(MsgText.DeleteEntryQuestion)
|
|
||||||
|
|
||||||
journal.entries = old_entries
|
|
||||||
|
|
||||||
if entries_to_delete:
|
|
||||||
journal.delete_entries(entries_to_delete)
|
|
||||||
|
|
||||||
journal.write()
|
|
||||||
|
|
||||||
|
|
||||||
def _change_time_search_results(
|
|
||||||
args: "Namespace",
|
|
||||||
journal: "Journal",
|
|
||||||
old_entries: list["Entry"],
|
|
||||||
no_prompt: bool = False,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
# separate entries we are not editing
|
|
||||||
# @todo if there's only 1, don't prompt
|
|
||||||
entries_to_change = journal.prompt_action_entries(MsgText.ChangeTimeEntryQuestion)
|
|
||||||
|
|
||||||
if entries_to_change:
|
|
||||||
date = time.parse(args.change_time)
|
|
||||||
journal.entries = old_entries
|
|
||||||
journal.change_date_entries(date, entries_to_change)
|
|
||||||
|
|
||||||
journal.write()
|
|
||||||
|
|
||||||
|
|
||||||
def _display_search_results(args: "Namespace", journal: "Journal", **kwargs) -> None:
|
|
||||||
if len(journal) == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get export format from config file if not provided at the command line
|
|
||||||
args.export = args.export or kwargs["config"].get("display_format")
|
|
||||||
|
|
||||||
if args.tags:
|
|
||||||
print(plugins.get_exporter("tags").export(journal))
|
|
||||||
|
|
||||||
elif args.short or args.export == "short":
|
|
||||||
print(journal.pprint(short=True))
|
|
||||||
|
|
||||||
elif args.export == "pretty":
|
|
||||||
print(journal.pprint())
|
|
||||||
|
|
||||||
elif args.export:
|
|
||||||
exporter = plugins.get_exporter(args.export)
|
|
||||||
print(exporter.export(journal, args.filename))
|
|
||||||
else:
|
|
||||||
print(journal.pprint())
|
|
||||||
|
|
||||||
|
|
||||||
def _has_search_args(args: "Namespace") -> bool:
|
|
||||||
"""Looking for arguments that filter a journal"""
|
|
||||||
return any(
|
|
||||||
(
|
|
||||||
args.contains,
|
|
||||||
args.tagged,
|
|
||||||
args.excluded,
|
|
||||||
args.exclude_starred,
|
|
||||||
args.exclude_tagged,
|
|
||||||
args.end_date,
|
|
||||||
args.today_in_history,
|
|
||||||
args.month,
|
|
||||||
args.day,
|
|
||||||
args.year,
|
|
||||||
args.limit,
|
|
||||||
args.on_date,
|
|
||||||
args.starred,
|
|
||||||
args.start_date,
|
|
||||||
args.strict, # -and
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_action_args(args: "Namespace") -> bool:
|
|
||||||
return any(
|
|
||||||
(
|
|
||||||
args.change_time,
|
|
||||||
args.delete,
|
|
||||||
args.edit,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_display_args(args: "Namespace") -> bool:
|
|
||||||
return any(
|
|
||||||
(
|
|
||||||
args.tags,
|
|
||||||
args.short,
|
|
||||||
args.export, # --format
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_only_tags(tag_symbols: str, args_text: str) -> bool:
|
|
||||||
return all(word[0] in tag_symbols for word in " ".join(args_text).split())
|
|
120
jrnl/editor.py
120
jrnl/editor.py
|
@ -1,120 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.os_compat import on_windows
|
|
||||||
from jrnl.os_compat import split_args
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.path import absolute_path
|
|
||||||
from jrnl.path import get_templates_path
|
|
||||||
|
|
||||||
|
|
||||||
def get_text_from_editor(config: dict, template: str = "") -> str:
|
|
||||||
suffix = ".jrnl"
|
|
||||||
if config["template"]:
|
|
||||||
template_filename = Path(config["template"]).name
|
|
||||||
suffix = "-" + template_filename
|
|
||||||
filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=suffix)
|
|
||||||
os.close(filehandle)
|
|
||||||
|
|
||||||
with open(tmpfile, "w", encoding="utf-8") as f:
|
|
||||||
if template:
|
|
||||||
f.write(template)
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.call(split_args(config["editor"]) + [tmpfile])
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.EditorMisconfigured,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{"editor_key": config["editor"]},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(tmpfile, "r", encoding="utf-8") as f:
|
|
||||||
raw = f.read()
|
|
||||||
os.remove(tmpfile)
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.NORMAL))
|
|
||||||
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def get_text_from_stdin() -> str:
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.WritingEntryStart,
|
|
||||||
MsgStyle.TITLE,
|
|
||||||
{
|
|
||||||
"how_to_quit": MsgText.HowToQuitWindows
|
|
||||||
if on_windows()
|
|
||||||
else MsgText.HowToQuitLinux
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = sys.stdin.read()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logging.error("Append mode: keyboard interrupt")
|
|
||||||
raise JrnlException(
|
|
||||||
Message(MsgText.KeyboardInterruptMsg, MsgStyle.ERROR_ON_NEW_LINE),
|
|
||||||
Message(MsgText.JournalNotSaved, MsgStyle.WARNING),
|
|
||||||
)
|
|
||||||
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def get_template_path(template_path: str, jrnl_template_dir: str) -> str:
|
|
||||||
actual_template_path = os.path.join(jrnl_template_dir, template_path)
|
|
||||||
if not os.path.exists(actual_template_path):
|
|
||||||
logging.debug(
|
|
||||||
f"Couldn't open {actual_template_path}. Treating template path like a local / abs path."
|
|
||||||
)
|
|
||||||
actual_template_path = absolute_path(template_path)
|
|
||||||
|
|
||||||
return actual_template_path
|
|
||||||
|
|
||||||
|
|
||||||
def read_template_file(template_path: str) -> str:
|
|
||||||
"""
|
|
||||||
Reads the template file given a template path in this order:
|
|
||||||
|
|
||||||
* Check $XDG_DATA_HOME/jrnl/templates/template_path.
|
|
||||||
* Check template_arg as an absolute / relative path.
|
|
||||||
|
|
||||||
If a file is found, its contents are returned as a string.
|
|
||||||
If not, a JrnlException is raised.
|
|
||||||
"""
|
|
||||||
|
|
||||||
jrnl_template_dir = get_templates_path()
|
|
||||||
actual_template_path = get_template_path(template_path, jrnl_template_dir)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(actual_template_path, encoding="utf-8") as f:
|
|
||||||
template_data = f.read()
|
|
||||||
return template_data
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.CantReadTemplate,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{
|
|
||||||
"template_path": template_path,
|
|
||||||
"actual_template_path": actual_template_path,
|
|
||||||
"jrnl_template_dir": str(jrnl_template_dir) + os.sep,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
|
@ -1,53 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from abc import ABC
|
|
||||||
from abc import abstractmethod
|
|
||||||
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
|
|
||||||
|
|
||||||
class BaseEncryption(ABC):
|
|
||||||
def __init__(self, journal_name: str, config: dict):
|
|
||||||
logging.debug("start")
|
|
||||||
self._encoding: str = "utf-8"
|
|
||||||
self._journal_name: str = journal_name
|
|
||||||
self._config: dict = config
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def encrypt(self, text: str) -> bytes:
|
|
||||||
logging.debug("encrypting")
|
|
||||||
return self._encrypt(text)
|
|
||||||
|
|
||||||
def decrypt(self, text: bytes) -> str:
|
|
||||||
logging.debug("decrypting")
|
|
||||||
if (result := self._decrypt(text)) is None:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(MsgText.DecryptionFailedGeneric, MsgStyle.ERROR)
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _encrypt(self, text: str) -> bytes:
|
|
||||||
"""
|
|
||||||
This is needed because self.decrypt might need
|
|
||||||
to perform actions (e.g. prompt for password)
|
|
||||||
before actually encrypting.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _decrypt(self, text: bytes) -> str | None:
|
|
||||||
"""
|
|
||||||
This is needed because self.decrypt might need
|
|
||||||
to perform actions (e.g. prompt for password)
|
|
||||||
before actually decrypting.
|
|
||||||
"""
|
|
||||||
pass
|
|
|
@ -1,8 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from .BaseEncryption import BaseEncryption
|
|
||||||
|
|
||||||
|
|
||||||
class BaseKeyEncryption(BaseEncryption):
|
|
||||||
pass
|
|
|
@ -1,82 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from jrnl.encryption.BaseEncryption import BaseEncryption
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.keyring import get_keyring_password
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.prompt import create_password
|
|
||||||
from jrnl.prompt import prompt_password
|
|
||||||
|
|
||||||
|
|
||||||
class BasePasswordEncryption(BaseEncryption):
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
logging.debug("start")
|
|
||||||
self._attempts: int = 0
|
|
||||||
self._max_attempts: int = 3
|
|
||||||
self._password: str = ""
|
|
||||||
self._check_keyring: bool = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def check_keyring(self) -> bool:
|
|
||||||
return self._check_keyring
|
|
||||||
|
|
||||||
@check_keyring.setter
|
|
||||||
def check_keyring(self, value: bool) -> None:
|
|
||||||
self._check_keyring = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def password(self) -> str | None:
|
|
||||||
return self._password
|
|
||||||
|
|
||||||
@password.setter
|
|
||||||
def password(self, value: str) -> None:
|
|
||||||
self._password = value
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
self.password = None
|
|
||||||
self.check_keyring = False
|
|
||||||
|
|
||||||
def encrypt(self, text: str) -> bytes:
|
|
||||||
logging.debug("encrypting")
|
|
||||||
if not self.password:
|
|
||||||
if self.check_keyring and (
|
|
||||||
keyring_pw := get_keyring_password(self._journal_name)
|
|
||||||
):
|
|
||||||
self.password = keyring_pw
|
|
||||||
|
|
||||||
if not self.password:
|
|
||||||
self.password = create_password(self._journal_name)
|
|
||||||
|
|
||||||
return self._encrypt(text)
|
|
||||||
|
|
||||||
def decrypt(self, text: bytes) -> str:
|
|
||||||
logging.debug("decrypting")
|
|
||||||
if not self.password:
|
|
||||||
if self.check_keyring and (
|
|
||||||
keyring_pw := get_keyring_password(self._journal_name)
|
|
||||||
):
|
|
||||||
self.password = keyring_pw
|
|
||||||
|
|
||||||
if not self.password:
|
|
||||||
self._prompt_password()
|
|
||||||
|
|
||||||
while (result := self._decrypt(text)) is None:
|
|
||||||
self._prompt_password()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _prompt_password(self) -> None:
|
|
||||||
if self._attempts >= self._max_attempts:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(MsgText.PasswordMaxTriesExceeded, MsgStyle.ERROR)
|
|
||||||
)
|
|
||||||
|
|
||||||
first_try = self._attempts == 0
|
|
||||||
self.password = prompt_password(first_try=first_try)
|
|
||||||
self._attempts += 1
|
|
|
@ -1,42 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives import padding
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher
|
|
||||||
from cryptography.hazmat.primitives.ciphers import algorithms
|
|
||||||
from cryptography.hazmat.primitives.ciphers import modes
|
|
||||||
|
|
||||||
from jrnl.encryption.BasePasswordEncryption import BasePasswordEncryption
|
|
||||||
|
|
||||||
|
|
||||||
class Jrnlv1Encryption(BasePasswordEncryption):
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
logging.debug("start")
|
|
||||||
|
|
||||||
def _encrypt(self, _: str) -> bytes:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def _decrypt(self, text: bytes) -> str | None:
|
|
||||||
logging.debug("decrypting")
|
|
||||||
iv, cipher = text[:16], text[16:]
|
|
||||||
password = self._password or ""
|
|
||||||
decryption_key = hashlib.sha256(password.encode(self._encoding)).digest()
|
|
||||||
decryptor = Cipher(
|
|
||||||
algorithms.AES(decryption_key), modes.CBC(iv), default_backend()
|
|
||||||
).decryptor()
|
|
||||||
try:
|
|
||||||
plain_padded = decryptor.update(cipher) + decryptor.finalize()
|
|
||||||
if plain_padded[-1] in (" ", 32):
|
|
||||||
# Ancient versions of jrnl. Do not judge me.
|
|
||||||
return plain_padded.decode(self._encoding).rstrip(" ")
|
|
||||||
else:
|
|
||||||
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
|
||||||
plain = unpadder.update(plain_padded) + unpadder.finalize()
|
|
||||||
return plain.decode(self._encoding)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
|
@ -1,59 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
from cryptography.fernet import InvalidToken
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
||||||
|
|
||||||
from .BasePasswordEncryption import BasePasswordEncryption
|
|
||||||
|
|
||||||
|
|
||||||
class Jrnlv2Encryption(BasePasswordEncryption):
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
|
||||||
# Salt is hard-coded
|
|
||||||
self._salt: bytes = b"\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8"
|
|
||||||
self._key: bytes = b""
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
logging.debug("start")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def password(self):
|
|
||||||
return self._password
|
|
||||||
|
|
||||||
@password.setter
|
|
||||||
def password(self, value: str | None):
|
|
||||||
self._password = value
|
|
||||||
self._make_key()
|
|
||||||
|
|
||||||
def _make_key(self) -> None:
|
|
||||||
if self._password is None:
|
|
||||||
# Password was removed after being set
|
|
||||||
self._key = None
|
|
||||||
return
|
|
||||||
password = self.password.encode(self._encoding)
|
|
||||||
kdf = PBKDF2HMAC(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=32,
|
|
||||||
salt=self._salt,
|
|
||||||
iterations=100_000,
|
|
||||||
backend=default_backend(),
|
|
||||||
)
|
|
||||||
key = kdf.derive(password)
|
|
||||||
self._key = base64.urlsafe_b64encode(key)
|
|
||||||
|
|
||||||
def _encrypt(self, text: str) -> bytes:
|
|
||||||
logging.debug("encrypting")
|
|
||||||
return Fernet(self._key).encrypt(text.encode(self._encoding))
|
|
||||||
|
|
||||||
def _decrypt(self, text: bytes) -> str | None:
|
|
||||||
logging.debug("decrypting")
|
|
||||||
try:
|
|
||||||
return Fernet(self._key).decrypt(text).decode(self._encoding)
|
|
||||||
except (InvalidToken, IndexError):
|
|
||||||
return None
|
|
|
@ -1,20 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from jrnl.encryption.BaseEncryption import BaseEncryption
|
|
||||||
|
|
||||||
|
|
||||||
class NoEncryption(BaseEncryption):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
logging.debug("start")
|
|
||||||
|
|
||||||
def _encrypt(self, text: str) -> bytes:
|
|
||||||
logging.debug("encrypting")
|
|
||||||
return text.encode(self._encoding)
|
|
||||||
|
|
||||||
def _decrypt(self, text: bytes) -> str:
|
|
||||||
logging.debug("decrypting")
|
|
||||||
return text.decode(self._encoding)
|
|
|
@ -1,36 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from importlib import import_module
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Type
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .BaseEncryption import BaseEncryption
|
|
||||||
|
|
||||||
|
|
||||||
class EncryptionMethods(str, Enum):
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
NONE = "NoEncryption"
|
|
||||||
JRNLV1 = "Jrnlv1Encryption"
|
|
||||||
JRNLV2 = "Jrnlv2Encryption"
|
|
||||||
|
|
||||||
|
|
||||||
def determine_encryption_method(config: str | bool) -> Type["BaseEncryption"]:
|
|
||||||
ENCRYPTION_METHODS = {
|
|
||||||
True: EncryptionMethods.JRNLV2, # the default
|
|
||||||
False: EncryptionMethods.NONE,
|
|
||||||
"jrnlv1": EncryptionMethods.JRNLV1,
|
|
||||||
"jrnlv2": EncryptionMethods.JRNLV2,
|
|
||||||
}
|
|
||||||
|
|
||||||
key = config
|
|
||||||
if isinstance(config, str):
|
|
||||||
key = config.lower()
|
|
||||||
|
|
||||||
my_class = ENCRYPTION_METHODS[key]
|
|
||||||
|
|
||||||
return getattr(import_module(f"jrnl.encryption.{my_class}"), my_class)
|
|
|
@ -1,24 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
|
|
||||||
|
|
||||||
class JrnlException(Exception):
|
|
||||||
"""Common exceptions raised by jrnl."""
|
|
||||||
|
|
||||||
def __init__(self, *messages: "Message"):
|
|
||||||
self.messages = messages
|
|
||||||
|
|
||||||
def print(self) -> None:
|
|
||||||
for msg in self.messages:
|
|
||||||
print_msg(msg)
|
|
||||||
|
|
||||||
def has_message_text(self, message_text: "MsgText"):
|
|
||||||
return any([m.text == message_text for m in self.messages])
|
|
183
jrnl/install.py
183
jrnl/install.py
|
@ -1,183 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import glob
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from rich.pretty import pretty_repr
|
|
||||||
|
|
||||||
from jrnl import __version__
|
|
||||||
from jrnl.config import DEFAULT_JOURNAL_KEY
|
|
||||||
from jrnl.config import get_config_path
|
|
||||||
from jrnl.config import get_default_colors
|
|
||||||
from jrnl.config import get_default_config
|
|
||||||
from jrnl.config import get_default_journal_path
|
|
||||||
from jrnl.config import load_config
|
|
||||||
from jrnl.config import save_config
|
|
||||||
from jrnl.config import verify_config_colors
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.path import absolute_path
|
|
||||||
from jrnl.path import expand_path
|
|
||||||
from jrnl.path import home_dir
|
|
||||||
from jrnl.prompt import yesno
|
|
||||||
from jrnl.upgrade import is_old_version
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade_config(config_data: dict, alt_config_path: str | None = None) -> None:
|
|
||||||
"""Checks if there are keys missing in a given config dict, and if so, updates the config file accordingly.
|
|
||||||
This essentially automatically ports jrnl installations if new config parameters are introduced in later
|
|
||||||
versions.
|
|
||||||
Also checks for existence of and difference in version number between config dict and current jrnl version,
|
|
||||||
and if so, update the config file accordingly.
|
|
||||||
Supply alt_config_path if using an alternate config through --config-file."""
|
|
||||||
default_config = get_default_config()
|
|
||||||
missing_keys = set(default_config).difference(config_data)
|
|
||||||
if missing_keys:
|
|
||||||
for key in missing_keys:
|
|
||||||
config_data[key] = default_config[key]
|
|
||||||
|
|
||||||
different_version = config_data["version"] != __version__
|
|
||||||
if different_version:
|
|
||||||
config_data["version"] = __version__
|
|
||||||
|
|
||||||
if missing_keys or different_version:
|
|
||||||
save_config(config_data, alt_config_path)
|
|
||||||
config_path = alt_config_path if alt_config_path else get_config_path()
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.ConfigUpdated, MsgStyle.NORMAL, {"config_path": config_path}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def find_default_config() -> str:
|
|
||||||
config_path = (
|
|
||||||
get_config_path()
|
|
||||||
if os.path.exists(get_config_path())
|
|
||||||
else os.path.join(home_dir(), ".jrnl_config")
|
|
||||||
)
|
|
||||||
return config_path
|
|
||||||
|
|
||||||
|
|
||||||
def find_alt_config(alt_config: str) -> str:
|
|
||||||
if not os.path.exists(alt_config):
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.AltConfigNotFound, MsgStyle.ERROR, {"config_file": alt_config}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return alt_config
|
|
||||||
|
|
||||||
|
|
||||||
def load_or_install_jrnl(alt_config_path: str) -> dict:
|
|
||||||
"""
|
|
||||||
If jrnl is already installed, loads and returns a default config object.
|
|
||||||
If alternate config is specified via --config-file flag, it will be used.
|
|
||||||
Else, perform various prompts to install jrnl.
|
|
||||||
"""
|
|
||||||
config_path = (
|
|
||||||
find_alt_config(alt_config_path) if alt_config_path else find_default_config()
|
|
||||||
)
|
|
||||||
|
|
||||||
if os.path.exists(config_path):
|
|
||||||
logging.debug("Reading configuration from file %s", config_path)
|
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
if config is None:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.CantParseConfigFile,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{
|
|
||||||
"config_path": config_path,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_old_version(config_path):
|
|
||||||
from jrnl import upgrade
|
|
||||||
|
|
||||||
upgrade.upgrade_jrnl(config_path)
|
|
||||||
|
|
||||||
upgrade_config(config, alt_config_path)
|
|
||||||
verify_config_colors(config)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logging.debug("Configuration file not found, installing jrnl...")
|
|
||||||
config = install()
|
|
||||||
|
|
||||||
logging.debug('Using configuration:\n"%s"', pretty_repr(config))
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def install() -> dict:
|
|
||||||
_initialize_autocomplete()
|
|
||||||
|
|
||||||
# Where to create the journal?
|
|
||||||
default_journal_path = get_default_journal_path()
|
|
||||||
user_given_path = print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.InstallJournalPathQuestion,
|
|
||||||
MsgStyle.PROMPT,
|
|
||||||
params={
|
|
||||||
"default_journal_path": default_journal_path,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
get_input=True,
|
|
||||||
)
|
|
||||||
journal_path = absolute_path(user_given_path or default_journal_path)
|
|
||||||
default_config = get_default_config()
|
|
||||||
default_config["journals"][DEFAULT_JOURNAL_KEY]["journal"] = journal_path
|
|
||||||
|
|
||||||
# If the folder doesn't exist, create it
|
|
||||||
path = os.path.split(journal_path)[0]
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.makedirs(path)
|
|
||||||
|
|
||||||
# Encrypt it?
|
|
||||||
encrypt = yesno(Message(MsgText.EncryptJournalQuestion), default=False)
|
|
||||||
if encrypt:
|
|
||||||
default_config["encrypt"] = True
|
|
||||||
print_msg(Message(MsgText.JournalEncrypted, MsgStyle.NORMAL))
|
|
||||||
|
|
||||||
# Use colors?
|
|
||||||
use_colors = yesno(Message(MsgText.UseColorsQuestion), default=True)
|
|
||||||
if use_colors:
|
|
||||||
default_config["colors"] = get_default_colors()
|
|
||||||
|
|
||||||
save_config(default_config)
|
|
||||||
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.InstallComplete,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
params={"config_path": get_config_path()},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return default_config
|
|
||||||
|
|
||||||
|
|
||||||
def _initialize_autocomplete() -> None:
|
|
||||||
# readline is not included in Windows Active Python and perhaps some other distributions
|
|
||||||
if sys.modules.get("readline"):
|
|
||||||
import readline
|
|
||||||
|
|
||||||
readline.set_completer_delims(" \t\n;")
|
|
||||||
readline.parse_and_bind("tab: complete")
|
|
||||||
readline.set_completer(_autocomplete_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _autocomplete_path(text: str, state: int) -> list[str | None]:
|
|
||||||
expansions = glob.glob(expand_path(text) + "*")
|
|
||||||
expansions = [e + "/" if os.path.isdir(e) else e for e in expansions]
|
|
||||||
expansions.append(None)
|
|
||||||
return expansions[state]
|
|
|
@ -1,219 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import datetime
|
|
||||||
import fnmatch
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import plistlib
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import zoneinfo
|
|
||||||
from pathlib import Path
|
|
||||||
from xml.parsers.expat import ExpatError
|
|
||||||
|
|
||||||
import tzlocal
|
|
||||||
|
|
||||||
from jrnl import __title__
|
|
||||||
from jrnl import __version__
|
|
||||||
|
|
||||||
from .Entry import Entry
|
|
||||||
from .Journal import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class DayOne(Journal):
|
|
||||||
"""A special Journal handling DayOne files"""
|
|
||||||
|
|
||||||
# InvalidFileException was added to plistlib in Python3.4
|
|
||||||
PLIST_EXCEPTIONS = (
|
|
||||||
(ExpatError, plistlib.InvalidFileException)
|
|
||||||
if hasattr(plistlib, "InvalidFileException")
|
|
||||||
else ExpatError
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.entries = []
|
|
||||||
self._deleted_entries = []
|
|
||||||
self.can_be_encrypted = False
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
def open(self) -> "DayOne":
|
|
||||||
filenames = []
|
|
||||||
for root, dirnames, f in os.walk(self.config["journal"]):
|
|
||||||
for filename in fnmatch.filter(f, "*.doentry"):
|
|
||||||
filenames.append(os.path.join(root, filename))
|
|
||||||
self.entries = []
|
|
||||||
for filename in filenames:
|
|
||||||
with open(filename, "rb") as plist_entry:
|
|
||||||
try:
|
|
||||||
dict_entry = plistlib.load(plist_entry, fmt=plistlib.FMT_XML)
|
|
||||||
except self.PLIST_EXCEPTIONS:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
timezone = zoneinfo.ZoneInfo(dict_entry["Time Zone"])
|
|
||||||
except KeyError:
|
|
||||||
timezone_name = str(tzlocal.get_localzone())
|
|
||||||
timezone = zoneinfo.ZoneInfo(timezone_name)
|
|
||||||
date = dict_entry["Creation Date"]
|
|
||||||
# convert the date to UTC rather than keep messing with
|
|
||||||
# timezones
|
|
||||||
if timezone.key != "UTC":
|
|
||||||
date = date.replace(fold=1) + timezone.utcoffset(date)
|
|
||||||
|
|
||||||
entry = Entry(
|
|
||||||
self,
|
|
||||||
date,
|
|
||||||
text=dict_entry["Entry Text"],
|
|
||||||
starred=dict_entry["Starred"],
|
|
||||||
)
|
|
||||||
entry.uuid = dict_entry["UUID"]
|
|
||||||
entry._tags = [
|
|
||||||
self.config["tagsymbols"][0] + tag.lower()
|
|
||||||
for tag in dict_entry.get("Tags", [])
|
|
||||||
]
|
|
||||||
if entry._tags:
|
|
||||||
entry._tags.sort()
|
|
||||||
|
|
||||||
"""Extended DayOne attributes"""
|
|
||||||
# just ignore it if the keys don't exist
|
|
||||||
with contextlib.suppress(KeyError):
|
|
||||||
entry.creator_device_agent = dict_entry["Creator"][
|
|
||||||
"Device Agent"
|
|
||||||
]
|
|
||||||
entry.creator_host_name = dict_entry["Creator"]["Host Name"]
|
|
||||||
entry.creator_os_agent = dict_entry["Creator"]["OS Agent"]
|
|
||||||
entry.creator_software_agent = dict_entry["Creator"][
|
|
||||||
"Software Agent"
|
|
||||||
]
|
|
||||||
entry.location = dict_entry["Location"]
|
|
||||||
entry.weather = dict_entry["Weather"]
|
|
||||||
|
|
||||||
entry.creator_generation_date = dict_entry.get("Creator", {}).get(
|
|
||||||
"Generation Date", date
|
|
||||||
)
|
|
||||||
|
|
||||||
self.entries.append(entry)
|
|
||||||
self.sort()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def write(self) -> None:
|
|
||||||
"""Writes only the entries that have been modified into plist files."""
|
|
||||||
for entry in self.entries:
|
|
||||||
if entry.modified:
|
|
||||||
utc_time = datetime.datetime.utcfromtimestamp(
|
|
||||||
time.mktime(entry.date.timetuple())
|
|
||||||
)
|
|
||||||
|
|
||||||
if not hasattr(entry, "uuid"):
|
|
||||||
entry.uuid = uuid.uuid1().hex
|
|
||||||
if not hasattr(entry, "creator_device_agent"):
|
|
||||||
entry.creator_device_agent = "" # iPhone/iPhone5,3
|
|
||||||
if not hasattr(entry, "creator_generation_date"):
|
|
||||||
entry.creator_generation_date = utc_time
|
|
||||||
if not hasattr(entry, "creator_host_name"):
|
|
||||||
entry.creator_host_name = socket.gethostname()
|
|
||||||
if not hasattr(entry, "creator_os_agent"):
|
|
||||||
entry.creator_os_agent = "{}/{}".format(
|
|
||||||
platform.system(), platform.release()
|
|
||||||
)
|
|
||||||
if not hasattr(entry, "creator_software_agent"):
|
|
||||||
entry.creator_software_agent = "{}/{}".format(
|
|
||||||
__title__, __version__
|
|
||||||
)
|
|
||||||
|
|
||||||
fn = (
|
|
||||||
Path(self.config["journal"])
|
|
||||||
/ "entries"
|
|
||||||
/ (entry.uuid.upper() + ".doentry")
|
|
||||||
)
|
|
||||||
|
|
||||||
entry_plist = {
|
|
||||||
"Creation Date": utc_time,
|
|
||||||
"Starred": entry.starred if hasattr(entry, "starred") else False,
|
|
||||||
"Entry Text": entry.title + "\n" + entry.body,
|
|
||||||
"Time Zone": str(tzlocal.get_localzone()),
|
|
||||||
"UUID": entry.uuid.upper(),
|
|
||||||
"Tags": [
|
|
||||||
tag.strip(self.config["tagsymbols"]).replace("_", " ")
|
|
||||||
for tag in entry.tags
|
|
||||||
],
|
|
||||||
"Creator": {
|
|
||||||
"Device Agent": entry.creator_device_agent,
|
|
||||||
"Generation Date": entry.creator_generation_date,
|
|
||||||
"Host Name": entry.creator_host_name,
|
|
||||||
"OS Agent": entry.creator_os_agent,
|
|
||||||
"Software Agent": entry.creator_software_agent,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if hasattr(entry, "location"):
|
|
||||||
entry_plist["Location"] = entry.location
|
|
||||||
if hasattr(entry, "weather"):
|
|
||||||
entry_plist["Weather"] = entry.weather
|
|
||||||
|
|
||||||
# plistlib expects a binary object
|
|
||||||
with fn.open(mode="wb") as f:
|
|
||||||
plistlib.dump(entry_plist, f, fmt=plistlib.FMT_XML, sort_keys=False)
|
|
||||||
|
|
||||||
for entry in self._deleted_entries:
|
|
||||||
filename = os.path.join(
|
|
||||||
self.config["journal"], "entries", entry.uuid + ".doentry"
|
|
||||||
)
|
|
||||||
os.remove(filename)
|
|
||||||
|
|
||||||
def editable_str(self) -> str:
|
|
||||||
"""Turns the journal into a string of entries that can be edited
|
|
||||||
manually and later be parsed with eslf.parse_editable_str."""
|
|
||||||
return "\n".join([f"{str(e)}\n# {e.uuid}\n" for e in self.entries])
|
|
||||||
|
|
||||||
def _update_old_entry(self, entry: Entry, new_entry: Entry) -> None:
|
|
||||||
for attr in ("title", "body", "date", "tags"):
|
|
||||||
old_attr = getattr(entry, attr)
|
|
||||||
new_attr = getattr(new_entry, attr)
|
|
||||||
if old_attr != new_attr:
|
|
||||||
entry.modified = True
|
|
||||||
setattr(entry, attr, new_attr)
|
|
||||||
|
|
||||||
def _get_and_remove_uuid_from_entry(self, entry: Entry) -> Entry:
|
|
||||||
uuid_regex = "^ *?# ([a-zA-Z0-9]+) *?$"
|
|
||||||
m = re.search(uuid_regex, entry.body, re.MULTILINE)
|
|
||||||
entry.uuid = m.group(1) if m else None
|
|
||||||
|
|
||||||
# remove the uuid from the body
|
|
||||||
entry.body = re.sub(uuid_regex, "", entry.body, flags=re.MULTILINE, count=1)
|
|
||||||
entry.body = entry.body.rstrip()
|
|
||||||
|
|
||||||
return entry
|
|
||||||
|
|
||||||
def parse_editable_str(self, edited: str) -> None:
|
|
||||||
"""Parses the output of self.editable_str and updates its entries."""
|
|
||||||
# Method: create a new list of entries from the edited text, then match
|
|
||||||
# UUIDs of the new entries against self.entries, updating the entries
|
|
||||||
# if the edited entries differ, and deleting entries from self.entries
|
|
||||||
# if they don't show up in the edited entries anymore.
|
|
||||||
entries_from_editor = self._parse(edited)
|
|
||||||
|
|
||||||
for entry in entries_from_editor:
|
|
||||||
entry = self._get_and_remove_uuid_from_entry(entry)
|
|
||||||
if entry._tags:
|
|
||||||
entry._tags.sort()
|
|
||||||
|
|
||||||
# Remove deleted entries
|
|
||||||
edited_uuids = [e.uuid for e in entries_from_editor]
|
|
||||||
self._deleted_entries = [e for e in self.entries if e.uuid not in edited_uuids]
|
|
||||||
self.entries[:] = [e for e in self.entries if e.uuid in edited_uuids]
|
|
||||||
|
|
||||||
for entry in entries_from_editor:
|
|
||||||
for old_entry in self.entries:
|
|
||||||
if entry.uuid == old_entry.uuid:
|
|
||||||
if old_entry._tags:
|
|
||||||
tags_not_in_body = [
|
|
||||||
tag for tag in old_entry._tags if (tag not in entry._body)
|
|
||||||
]
|
|
||||||
if tags_not_in_body:
|
|
||||||
entry._tags.extend(tags_not_in_body.sort())
|
|
||||||
self._update_old_entry(old_entry, entry)
|
|
||||||
break
|
|
|
@ -1,250 +0,0 @@
|
||||||
# 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 a string representation of the entry to be written into a 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.
|
|
||||||
""",
|
|
||||||
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()
|
|
|
@ -1,157 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import codecs
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl import time
|
|
||||||
|
|
||||||
from .Journal import Journal
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
|
|
||||||
# glob search patterns for folder/file structure
|
|
||||||
DIGIT_PATTERN = "[0123456789]"
|
|
||||||
YEAR_PATTERN = DIGIT_PATTERN * 4
|
|
||||||
MONTH_PATTERN = "[01]" + DIGIT_PATTERN
|
|
||||||
DAY_PATTERN = "[0123]" + DIGIT_PATTERN + ".txt"
|
|
||||||
|
|
||||||
|
|
||||||
class Folder(Journal):
|
|
||||||
"""A Journal handling multiple files in a folder"""
|
|
||||||
|
|
||||||
def __init__(self, name: str = "default", **kwargs):
|
|
||||||
self.entries = []
|
|
||||||
self._diff_entry_dates = []
|
|
||||||
self.can_be_encrypted = False
|
|
||||||
super().__init__(name, **kwargs)
|
|
||||||
|
|
||||||
def open(self) -> "Folder":
|
|
||||||
filenames = []
|
|
||||||
self.entries = []
|
|
||||||
|
|
||||||
if os.path.exists(self.config["journal"]):
|
|
||||||
filenames = Folder._get_files(self.config["journal"])
|
|
||||||
for filename in filenames:
|
|
||||||
with codecs.open(filename, "r", "utf-8") as f:
|
|
||||||
journal = f.read()
|
|
||||||
self.entries.extend(self._parse(journal))
|
|
||||||
self.sort()
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def write(self) -> None:
|
|
||||||
"""Writes only the entries that have been modified into proper files."""
|
|
||||||
# Create a list of dates of modified entries. Start with diff_entry_dates
|
|
||||||
modified_dates = self._diff_entry_dates
|
|
||||||
seen_dates = set(self._diff_entry_dates)
|
|
||||||
|
|
||||||
for e in self.entries:
|
|
||||||
if e.modified:
|
|
||||||
if e.date not in modified_dates:
|
|
||||||
modified_dates.append(e.date)
|
|
||||||
if e.date not in seen_dates:
|
|
||||||
seen_dates.add(e.date)
|
|
||||||
|
|
||||||
# For every date that had a modified entry, write to a file
|
|
||||||
for d in modified_dates:
|
|
||||||
write_entries = []
|
|
||||||
filename = os.path.join(
|
|
||||||
self.config["journal"],
|
|
||||||
d.strftime("%Y"),
|
|
||||||
d.strftime("%m"),
|
|
||||||
d.strftime("%d") + ".txt",
|
|
||||||
)
|
|
||||||
dirname = os.path.dirname(filename)
|
|
||||||
# create directory if it doesn't exist
|
|
||||||
if not os.path.exists(dirname):
|
|
||||||
os.makedirs(dirname)
|
|
||||||
for e in self.entries:
|
|
||||||
if (
|
|
||||||
e.date.year == d.year
|
|
||||||
and e.date.month == d.month
|
|
||||||
and e.date.day == d.day
|
|
||||||
):
|
|
||||||
write_entries.append(e)
|
|
||||||
journal = "\n".join([e.__str__() for e in write_entries])
|
|
||||||
with codecs.open(filename, "w", "utf-8") as journal_file:
|
|
||||||
journal_file.write(journal)
|
|
||||||
# look for and delete empty files
|
|
||||||
filenames = []
|
|
||||||
filenames = Folder._get_files(self.config["journal"])
|
|
||||||
for filename in filenames:
|
|
||||||
if os.stat(filename).st_size <= 0:
|
|
||||||
os.remove(filename)
|
|
||||||
|
|
||||||
def delete_entries(self, entries_to_delete: list["Entry"]) -> None:
|
|
||||||
"""Deletes specific entries from a journal."""
|
|
||||||
for entry in entries_to_delete:
|
|
||||||
self.entries.remove(entry)
|
|
||||||
self._diff_entry_dates.append(entry.date)
|
|
||||||
self.deleted_entry_count += 1
|
|
||||||
|
|
||||||
def change_date_entries(self, date: str, entries_to_change: list["Entry"]) -> None:
|
|
||||||
"""Changes entry dates to given date."""
|
|
||||||
|
|
||||||
date = time.parse(date)
|
|
||||||
|
|
||||||
self._diff_entry_dates.append(date)
|
|
||||||
|
|
||||||
for entry in entries_to_change:
|
|
||||||
self._diff_entry_dates.append(entry.date)
|
|
||||||
entry.date = date
|
|
||||||
entry.modified = True
|
|
||||||
|
|
||||||
def parse_editable_str(self, edited: str) -> None:
|
|
||||||
"""Parses the output of self.editable_str and updates its entries."""
|
|
||||||
mod_entries = self._parse(edited)
|
|
||||||
diff_entries = set(self.entries) - set(mod_entries)
|
|
||||||
for e in diff_entries:
|
|
||||||
self._diff_entry_dates.append(e.date)
|
|
||||||
# Match those entries that can be found in self.entries and set
|
|
||||||
# these to modified, so we can get a count of how many entries got
|
|
||||||
# modified and how many got deleted later.
|
|
||||||
for entry in mod_entries:
|
|
||||||
entry.modified = not any(entry == old_entry for old_entry in self.entries)
|
|
||||||
|
|
||||||
self.increment_change_counts_by_edit(mod_entries)
|
|
||||||
self.entries = mod_entries
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_files(journal_path: str) -> list[str]:
|
|
||||||
"""Searches through sub directories starting with journal_path and find all text files that look like entries"""
|
|
||||||
for year_folder in Folder._get_year_folders(pathlib.Path(journal_path)):
|
|
||||||
for month_folder in Folder._get_month_folders(year_folder):
|
|
||||||
yield from Folder._get_day_files(month_folder)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_year_folders(path: pathlib.Path) -> list[pathlib.Path]:
|
|
||||||
for child in path.glob(YEAR_PATTERN):
|
|
||||||
if child.is_dir():
|
|
||||||
yield child
|
|
||||||
return
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_month_folders(path: pathlib.Path) -> list[pathlib.Path]:
|
|
||||||
for child in path.glob(MONTH_PATTERN):
|
|
||||||
if int(child.name) > 0 and int(child.name) <= 12 and path.is_dir():
|
|
||||||
yield child
|
|
||||||
return
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_day_files(path: pathlib.Path) -> list[str]:
|
|
||||||
for child in path.glob(DAY_PATTERN):
|
|
||||||
if (
|
|
||||||
int(child.stem) > 0
|
|
||||||
and int(child.stem) <= 31
|
|
||||||
and time.is_valid_date(
|
|
||||||
year=int(path.parent.name),
|
|
||||||
month=int(path.name),
|
|
||||||
day=int(child.stem),
|
|
||||||
)
|
|
||||||
and child.is_file()
|
|
||||||
):
|
|
||||||
yield str(child)
|
|
|
@ -1,511 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
from jrnl import time
|
|
||||||
from jrnl.config import validate_journal_name
|
|
||||||
from jrnl.encryption import determine_encryption_method
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.path import expand_path
|
|
||||||
from jrnl.prompt import yesno
|
|
||||||
|
|
||||||
from .Entry import Entry
|
|
||||||
|
|
||||||
|
|
||||||
class Tag:
|
|
||||||
def __init__(self, name, count=0):
|
|
||||||
self.name = name
|
|
||||||
self.count = count
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<Tag '{self.name}'>"
|
|
||||||
|
|
||||||
|
|
||||||
class Journal:
|
|
||||||
def __init__(self, name="default", **kwargs):
|
|
||||||
self.config = {
|
|
||||||
"journal": "journal.txt",
|
|
||||||
"encrypt": False,
|
|
||||||
"default_hour": 9,
|
|
||||||
"default_minute": 0,
|
|
||||||
"timeformat": "%Y-%m-%d %H:%M",
|
|
||||||
"tagsymbols": "@",
|
|
||||||
"highlight": True,
|
|
||||||
"linewrap": 80,
|
|
||||||
"indent_character": "|",
|
|
||||||
}
|
|
||||||
self.config.update(kwargs)
|
|
||||||
# Set up date parser
|
|
||||||
self.search_tags = None # Store tags we're highlighting
|
|
||||||
self.name = name
|
|
||||||
self.entries = []
|
|
||||||
self.encryption_method = None
|
|
||||||
|
|
||||||
# Track changes to journal in session. Modified is tracked in Entry
|
|
||||||
self.added_entry_count = 0
|
|
||||||
self.deleted_entry_count = 0
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
"""Returns the number of entries"""
|
|
||||||
return len(self.entries)
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
"""Iterates over the journal's entries."""
|
|
||||||
return (entry for entry in self.entries)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_journal(cls, other: "Journal") -> "Journal":
|
|
||||||
"""Creates a new journal by copying configuration and entries from
|
|
||||||
another journal object"""
|
|
||||||
new_journal = cls(other.name, **other.config)
|
|
||||||
new_journal.entries = other.entries
|
|
||||||
logging.debug(
|
|
||||||
"Imported %d entries from %s to %s",
|
|
||||||
len(new_journal),
|
|
||||||
other.__class__.__name__,
|
|
||||||
cls.__name__,
|
|
||||||
)
|
|
||||||
return new_journal
|
|
||||||
|
|
||||||
def import_(self, other_journal_txt: str) -> None:
|
|
||||||
imported_entries = self._parse(other_journal_txt)
|
|
||||||
for entry in imported_entries:
|
|
||||||
entry.modified = True
|
|
||||||
|
|
||||||
self.entries = list(frozenset(self.entries) | frozenset(imported_entries))
|
|
||||||
self.sort()
|
|
||||||
|
|
||||||
def _get_encryption_method(self) -> None:
|
|
||||||
encryption_method = determine_encryption_method(self.config["encrypt"])
|
|
||||||
self.encryption_method = encryption_method(self.name, self.config)
|
|
||||||
|
|
||||||
def _decrypt(self, text: bytes) -> str:
|
|
||||||
if self.encryption_method is None:
|
|
||||||
self._get_encryption_method()
|
|
||||||
|
|
||||||
return self.encryption_method.decrypt(text)
|
|
||||||
|
|
||||||
def _encrypt(self, text: str) -> bytes:
|
|
||||||
if self.encryption_method is None:
|
|
||||||
self._get_encryption_method()
|
|
||||||
|
|
||||||
return self.encryption_method.encrypt(text)
|
|
||||||
|
|
||||||
def open(self, filename: str | None = None) -> "Journal":
|
|
||||||
"""Opens the journal file defined in the config and parses it into a list of Entries.
|
|
||||||
Entries have the form (date, title, body)."""
|
|
||||||
filename = filename or self.config["journal"]
|
|
||||||
dirname = os.path.dirname(filename)
|
|
||||||
if not os.path.exists(filename):
|
|
||||||
if not os.path.isdir(dirname):
|
|
||||||
os.makedirs(dirname)
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.DirectoryCreated,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{"directory_name": dirname},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.create_file(filename)
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.JournalCreated,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{
|
|
||||||
"journal_name": self.name,
|
|
||||||
"filename": filename,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.write()
|
|
||||||
|
|
||||||
text = self._load(filename)
|
|
||||||
text = self._decrypt(text)
|
|
||||||
self.entries = self._parse(text)
|
|
||||||
self.sort()
|
|
||||||
logging.debug("opened %s with %d entries", self.__class__.__name__, len(self))
|
|
||||||
return self
|
|
||||||
|
|
||||||
def write(self, filename: str | None = None) -> None:
|
|
||||||
"""Dumps the journal into the config file, overwriting it"""
|
|
||||||
filename = filename or self.config["journal"]
|
|
||||||
text = self._to_text()
|
|
||||||
text = self._encrypt(text)
|
|
||||||
self._store(filename, text)
|
|
||||||
|
|
||||||
def validate_parsing(self) -> bool:
|
|
||||||
"""Confirms that the jrnl is still parsed correctly after being dumped to text."""
|
|
||||||
new_entries = self._parse(self._to_text())
|
|
||||||
return all(entry == new_entries[i] for i, entry in enumerate(self.entries))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_file(filename: str) -> None:
|
|
||||||
with open(filename, "w"):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _to_text(self) -> str:
|
|
||||||
return "\n".join([str(e) for e in self.entries])
|
|
||||||
|
|
||||||
def _load(self, filename: str) -> bytes:
|
|
||||||
with open(filename, "rb") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def _store(self, filename: str, text: bytes) -> None:
|
|
||||||
with open(filename, "wb") as f:
|
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
def _parse(self, journal_txt: str) -> list[Entry]:
|
|
||||||
"""Parses a journal that's stored in a string and returns a list of entries"""
|
|
||||||
|
|
||||||
# Return empty array if the journal is blank
|
|
||||||
if not journal_txt:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Initialise our current entry
|
|
||||||
entries = []
|
|
||||||
|
|
||||||
date_blob_re = re.compile("(?:^|\n)\\[([^\\]]+)\\] ")
|
|
||||||
last_entry_pos = 0
|
|
||||||
for match in date_blob_re.finditer(journal_txt):
|
|
||||||
date_blob = match.groups()[0]
|
|
||||||
try:
|
|
||||||
new_date = datetime.datetime.strptime(
|
|
||||||
date_blob, self.config["timeformat"]
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# Passing in a date that had brackets around it
|
|
||||||
new_date = time.parse(date_blob, bracketed=True)
|
|
||||||
|
|
||||||
if new_date:
|
|
||||||
if entries:
|
|
||||||
entries[-1].text = journal_txt[last_entry_pos : match.start()]
|
|
||||||
last_entry_pos = match.end()
|
|
||||||
entries.append(Entry(self, date=new_date))
|
|
||||||
|
|
||||||
# If no entries were found, treat all the existing text as an entry made now
|
|
||||||
if not entries:
|
|
||||||
entries.append(Entry(self, date=time.parse("now")))
|
|
||||||
|
|
||||||
# Fill in the text of the last entry
|
|
||||||
entries[-1].text = journal_txt[last_entry_pos:]
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
entry._parse_text()
|
|
||||||
return entries
|
|
||||||
|
|
||||||
def pprint(self, short: bool = False) -> str:
|
|
||||||
"""Prettyprints the journal's entries"""
|
|
||||||
return "\n".join([e.pprint(short=short) for e in self.entries])
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.pprint()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<Journal with {len(self.entries)} entries>"
|
|
||||||
|
|
||||||
def sort(self) -> None:
|
|
||||||
"""Sorts the Journal's entries by date"""
|
|
||||||
self.entries = sorted(self.entries, key=lambda entry: entry.date)
|
|
||||||
|
|
||||||
def limit(self, n: int | None = None) -> None:
|
|
||||||
"""Removes all but the last n entries"""
|
|
||||||
if n:
|
|
||||||
self.entries = self.entries[-n:]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tags(self) -> list[Tag]:
|
|
||||||
"""Returns a set of tuples (count, tag) for all tags present in the journal."""
|
|
||||||
# Astute reader: should the following line leave you as puzzled as me the first time
|
|
||||||
# I came across this construction, worry not and embrace the ensuing moment of enlightment.
|
|
||||||
tags = [tag for entry in self.entries for tag in set(entry.tags)]
|
|
||||||
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
|
|
||||||
tag_counts = {(tags.count(tag), tag) for tag in tags}
|
|
||||||
return [Tag(tag, count=count) for count, tag in sorted(tag_counts)]
|
|
||||||
|
|
||||||
def filter(
|
|
||||||
self,
|
|
||||||
tags=[],
|
|
||||||
month=None,
|
|
||||||
day=None,
|
|
||||||
year=None,
|
|
||||||
start_date=None,
|
|
||||||
end_date=None,
|
|
||||||
starred=False,
|
|
||||||
tagged=False,
|
|
||||||
exclude_starred=False,
|
|
||||||
exclude_tagged=False,
|
|
||||||
strict=False,
|
|
||||||
contains=None,
|
|
||||||
exclude=[],
|
|
||||||
):
|
|
||||||
"""Removes all entries from the journal that don't match the filter.
|
|
||||||
|
|
||||||
tags is a list of tags, each being a string that starts with one of the
|
|
||||||
tag symbols defined in the config, e.g. ["@John", "#WorldDomination"].
|
|
||||||
|
|
||||||
start_date and end_date define a timespan by which to filter.
|
|
||||||
|
|
||||||
starred limits journal to starred entries
|
|
||||||
|
|
||||||
If strict is True, all tags must be present in an entry. If false, the
|
|
||||||
|
|
||||||
exclude is a list of the tags which should not appear in the results.
|
|
||||||
entry is kept if any tag is present, unless they appear in exclude."""
|
|
||||||
self.search_tags = {tag.lower() for tag in tags}
|
|
||||||
excluded_tags = {tag.lower() for tag in exclude}
|
|
||||||
end_date = time.parse(end_date, inclusive=True)
|
|
||||||
start_date = time.parse(start_date)
|
|
||||||
|
|
||||||
# If strict mode is on, all tags have to be present in entry
|
|
||||||
has_tags = (
|
|
||||||
self.search_tags.issubset if strict else self.search_tags.intersection
|
|
||||||
)
|
|
||||||
|
|
||||||
def excluded(tags):
|
|
||||||
return 0 < len([tag for tag in tags if tag in excluded_tags])
|
|
||||||
|
|
||||||
if contains:
|
|
||||||
contains_lower = contains.casefold()
|
|
||||||
|
|
||||||
# Create datetime object for comparison below
|
|
||||||
# this approach allows various formats
|
|
||||||
if month or day or year:
|
|
||||||
compare_d = time.parse(f"{month or 1}.{day or 1}.{year or 1}")
|
|
||||||
|
|
||||||
result = [
|
|
||||||
entry
|
|
||||||
for entry in self.entries
|
|
||||||
if (not tags or has_tags(entry.tags))
|
|
||||||
and (not (starred or exclude_starred) or entry.starred == starred)
|
|
||||||
and (not (tagged or exclude_tagged) or bool(entry.tags) == tagged)
|
|
||||||
and (not month or entry.date.month == compare_d.month)
|
|
||||||
and (not day or entry.date.day == compare_d.day)
|
|
||||||
and (not year or entry.date.year == compare_d.year)
|
|
||||||
and (not start_date or entry.date >= start_date)
|
|
||||||
and (not end_date or entry.date <= end_date)
|
|
||||||
and (not exclude or not excluded(entry.tags))
|
|
||||||
and (
|
|
||||||
not contains
|
|
||||||
or (
|
|
||||||
contains_lower in entry.title.casefold()
|
|
||||||
or contains_lower in entry.body.casefold()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
self.entries = result
|
|
||||||
|
|
||||||
def delete_entries(self, entries_to_delete: list[Entry]) -> None:
|
|
||||||
"""Deletes specific entries from a journal."""
|
|
||||||
for entry in entries_to_delete:
|
|
||||||
self.entries.remove(entry)
|
|
||||||
self.deleted_entry_count += 1
|
|
||||||
|
|
||||||
def change_date_entries(
|
|
||||||
self, date: datetime.datetime, entries_to_change: list[Entry]
|
|
||||||
) -> None:
|
|
||||||
"""Changes entry dates to given date."""
|
|
||||||
date = time.parse(date)
|
|
||||||
|
|
||||||
for entry in entries_to_change:
|
|
||||||
entry.date = date
|
|
||||||
entry.modified = True
|
|
||||||
|
|
||||||
def prompt_action_entries(self, msg: MsgText) -> list[Entry]:
|
|
||||||
"""Prompts for action for each entry in a journal, using given message.
|
|
||||||
Returns the entries the user wishes to apply the action on."""
|
|
||||||
to_act = []
|
|
||||||
|
|
||||||
def ask_action(entry):
|
|
||||||
return yesno(
|
|
||||||
Message(
|
|
||||||
msg,
|
|
||||||
params={"entry_title": entry.pprint(short=True)},
|
|
||||||
),
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
for entry in self.entries:
|
|
||||||
if ask_action(entry):
|
|
||||||
to_act.append(entry)
|
|
||||||
|
|
||||||
return to_act
|
|
||||||
|
|
||||||
def new_entry(self, raw: str, date=None, sort: bool = True) -> Entry:
|
|
||||||
"""Constructs a new entry from some raw text input.
|
|
||||||
If a date is given, it will parse and use this, otherwise scan for a date in the input first.
|
|
||||||
"""
|
|
||||||
|
|
||||||
raw = raw.replace("\\n ", "\n").replace("\\n", "\n")
|
|
||||||
# Split raw text into title and body
|
|
||||||
sep = re.search(r"\n|[?!.]+ +\n?", raw)
|
|
||||||
first_line = raw[: sep.end()].strip() if sep else raw
|
|
||||||
starred = False
|
|
||||||
|
|
||||||
if not date:
|
|
||||||
colon_pos = first_line.find(": ")
|
|
||||||
if colon_pos > 0:
|
|
||||||
date = time.parse(
|
|
||||||
raw[:colon_pos],
|
|
||||||
default_hour=self.config["default_hour"],
|
|
||||||
default_minute=self.config["default_minute"],
|
|
||||||
)
|
|
||||||
if date: # Parsed successfully, strip that from the raw text
|
|
||||||
starred = raw[:colon_pos].strip().endswith("*")
|
|
||||||
raw = raw[colon_pos + 1 :].strip()
|
|
||||||
starred = (
|
|
||||||
starred
|
|
||||||
or first_line.startswith("*")
|
|
||||||
or first_line.endswith("*")
|
|
||||||
or raw.startswith("*")
|
|
||||||
)
|
|
||||||
if not date: # Still nothing? Meh, just live in the moment.
|
|
||||||
date = time.parse("now")
|
|
||||||
entry = Entry(self, date, raw, starred=starred)
|
|
||||||
entry.modified = True
|
|
||||||
self.entries.append(entry)
|
|
||||||
if sort:
|
|
||||||
self.sort()
|
|
||||||
return entry
|
|
||||||
|
|
||||||
def editable_str(self) -> str:
|
|
||||||
"""Turns the journal into a string of entries that can be edited
|
|
||||||
manually and later be parsed with self.parse_editable_str."""
|
|
||||||
return "\n".join([str(e) for e in self.entries])
|
|
||||||
|
|
||||||
def parse_editable_str(self, edited: str) -> None:
|
|
||||||
"""Parses the output of self.editable_str and updates it's entries."""
|
|
||||||
mod_entries = self._parse(edited)
|
|
||||||
# Match those entries that can be found in self.entries and set
|
|
||||||
# these to modified, so we can get a count of how many entries got
|
|
||||||
# modified and how many got deleted later.
|
|
||||||
for entry in mod_entries:
|
|
||||||
entry.modified = not any(entry == old_entry for old_entry in self.entries)
|
|
||||||
|
|
||||||
self.increment_change_counts_by_edit(mod_entries)
|
|
||||||
|
|
||||||
self.entries = mod_entries
|
|
||||||
|
|
||||||
def increment_change_counts_by_edit(self, mod_entries: Entry) -> None:
|
|
||||||
if len(mod_entries) > len(self.entries):
|
|
||||||
self.added_entry_count += len(mod_entries) - len(self.entries)
|
|
||||||
else:
|
|
||||||
self.deleted_entry_count += len(self.entries) - len(mod_entries)
|
|
||||||
|
|
||||||
def get_change_counts(self) -> dict:
|
|
||||||
return {
|
|
||||||
"added": self.added_entry_count,
|
|
||||||
"deleted": self.deleted_entry_count,
|
|
||||||
"modified": len([e for e in self.entries if e.modified]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LegacyJournal(Journal):
|
|
||||||
"""Legacy class to support opening journals formatted with the jrnl 1.x
|
|
||||||
standard. Main difference here is that in 1.x, timestamps were not cuddled
|
|
||||||
by square brackets. You'll not be able to save these journals anymore."""
|
|
||||||
|
|
||||||
def _parse(self, journal_txt: str) -> list[Entry]:
|
|
||||||
"""Parses a journal that's stored in a string and returns a list of entries"""
|
|
||||||
# Entries start with a line that looks like 'date title' - let's figure out how
|
|
||||||
# long the date will be by constructing one
|
|
||||||
date_length = len(datetime.datetime.today().strftime(self.config["timeformat"]))
|
|
||||||
|
|
||||||
# Initialise our current entry
|
|
||||||
entries = []
|
|
||||||
current_entry = None
|
|
||||||
new_date_format_regex = re.compile(r"(^\[[^\]]+\].*?$)")
|
|
||||||
for line in journal_txt.splitlines():
|
|
||||||
line = line.rstrip()
|
|
||||||
try:
|
|
||||||
# try to parse line as date => new entry begins
|
|
||||||
new_date = datetime.datetime.strptime(
|
|
||||||
line[:date_length], self.config["timeformat"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# parsing successful => save old entry and create new one
|
|
||||||
if new_date and current_entry:
|
|
||||||
entries.append(current_entry)
|
|
||||||
|
|
||||||
if line.endswith("*"):
|
|
||||||
starred = True
|
|
||||||
line = line[:-1]
|
|
||||||
else:
|
|
||||||
starred = False
|
|
||||||
|
|
||||||
current_entry = Entry(
|
|
||||||
self, date=new_date, text=line[date_length + 1 :], starred=starred
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# Happens when we can't parse the start of the line as an date.
|
|
||||||
# In this case, just append line to our body (after some
|
|
||||||
# escaping for the new format).
|
|
||||||
line = new_date_format_regex.sub(r" \1", line)
|
|
||||||
if current_entry:
|
|
||||||
current_entry.text += line + "\n"
|
|
||||||
|
|
||||||
# Append last entry
|
|
||||||
if current_entry:
|
|
||||||
entries.append(current_entry)
|
|
||||||
for entry in entries:
|
|
||||||
entry._parse_text()
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def open_journal(journal_name: str, config: dict, legacy: bool = False) -> Journal:
|
|
||||||
"""
|
|
||||||
Creates a normal, encrypted or DayOne journal based on the passed config.
|
|
||||||
If legacy is True, it will open Journals with legacy classes build for
|
|
||||||
backwards compatibility with jrnl 1.x
|
|
||||||
"""
|
|
||||||
logging.debug(f"open_journal '{journal_name}'")
|
|
||||||
validate_journal_name(journal_name, config)
|
|
||||||
config = config.copy()
|
|
||||||
config["journal"] = expand_path(config["journal"])
|
|
||||||
|
|
||||||
if os.path.isdir(config["journal"]):
|
|
||||||
if config["encrypt"]:
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.ConfigEncryptedForUnencryptableJournalType,
|
|
||||||
MsgStyle.WARNING,
|
|
||||||
{
|
|
||||||
"journal_name": journal_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if config["journal"].strip("/").endswith(".dayone") or "entries" in os.listdir(
|
|
||||||
config["journal"]
|
|
||||||
):
|
|
||||||
from jrnl.journals import DayOne
|
|
||||||
|
|
||||||
return DayOne(**config).open()
|
|
||||||
else:
|
|
||||||
from jrnl.journals import Folder
|
|
||||||
|
|
||||||
return Folder(journal_name, **config).open()
|
|
||||||
|
|
||||||
if not config["encrypt"]:
|
|
||||||
if legacy:
|
|
||||||
return LegacyJournal(journal_name, **config).open()
|
|
||||||
if config["journal"].endswith(os.sep):
|
|
||||||
from jrnl.journals import Folder
|
|
||||||
|
|
||||||
return Folder(journal_name, **config).open()
|
|
||||||
return Journal(journal_name, **config).open()
|
|
||||||
|
|
||||||
if legacy:
|
|
||||||
config["encrypt"] = "jrnlv1"
|
|
||||||
return LegacyJournal(journal_name, **config).open()
|
|
||||||
return Journal(journal_name, **config).open()
|
|
|
@ -1,5 +0,0 @@
|
||||||
from .DayOneJournal import DayOne
|
|
||||||
from .Entry import Entry
|
|
||||||
from .FolderJournal import Folder
|
|
||||||
from .Journal import Journal
|
|
||||||
from .Journal import open_journal
|
|
|
@ -1,29 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import keyring
|
|
||||||
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
|
|
||||||
|
|
||||||
def get_keyring_password(journal_name: str = "default") -> str | None:
|
|
||||||
try:
|
|
||||||
return keyring.get_password("jrnl", journal_name)
|
|
||||||
except keyring.errors.KeyringError as e:
|
|
||||||
if not isinstance(e, keyring.errors.NoKeyringError):
|
|
||||||
print_msg(Message(MsgText.KeyringRetrievalFailure, MsgStyle.ERROR))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def set_keyring_password(password: str, journal_name: str = "default") -> None:
|
|
||||||
try:
|
|
||||||
return keyring.set_password("jrnl", journal_name, password)
|
|
||||||
except keyring.errors.KeyringError as e:
|
|
||||||
if isinstance(e, keyring.errors.NoKeyringError):
|
|
||||||
msg = Message(MsgText.KeyringBackendNotFound, MsgStyle.WARNING)
|
|
||||||
else:
|
|
||||||
msg = Message(MsgText.KeyringRetrievalFailure, MsgStyle.ERROR)
|
|
||||||
print_msg(msg)
|
|
|
@ -1,17 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Mapping
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
from jrnl.messages.MsgStyle import MsgStyle
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.messages.MsgText import MsgText
|
|
||||||
|
|
||||||
|
|
||||||
class Message(NamedTuple):
|
|
||||||
text: "MsgText"
|
|
||||||
style: MsgStyle = MsgStyle.NORMAL
|
|
||||||
params: Mapping = {}
|
|
|
@ -1,93 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Callable
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
from rich import box
|
|
||||||
from rich.panel import Panel
|
|
||||||
|
|
||||||
from jrnl.messages.MsgText import MsgText
|
|
||||||
|
|
||||||
|
|
||||||
class MsgStyle(Enum):
|
|
||||||
class _Color(NamedTuple):
|
|
||||||
"""
|
|
||||||
String representing a standard color to display
|
|
||||||
see: https://rich.readthedocs.io/en/stable/appendix/colors.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
color: str
|
|
||||||
|
|
||||||
class _Decoration(Enum):
|
|
||||||
NONE = {
|
|
||||||
"callback": lambda x, **_: x,
|
|
||||||
"args": {},
|
|
||||||
}
|
|
||||||
BOX = {
|
|
||||||
"callback": Panel,
|
|
||||||
"args": {
|
|
||||||
"expand": False,
|
|
||||||
"padding": (0, 2),
|
|
||||||
"title_align": "left",
|
|
||||||
"box": box.HEAVY,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def callback(self) -> Callable:
|
|
||||||
return self.value["callback"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def args(self) -> dict:
|
|
||||||
return self.value["args"]
|
|
||||||
|
|
||||||
PROMPT = {
|
|
||||||
"decoration": _Decoration.NONE,
|
|
||||||
"color": _Color("white"),
|
|
||||||
"append_space": True,
|
|
||||||
}
|
|
||||||
TITLE = {
|
|
||||||
"decoration": _Decoration.BOX,
|
|
||||||
"color": _Color("cyan"),
|
|
||||||
}
|
|
||||||
NORMAL = {
|
|
||||||
"decoration": _Decoration.BOX,
|
|
||||||
"color": _Color("white"),
|
|
||||||
}
|
|
||||||
WARNING = {
|
|
||||||
"decoration": _Decoration.BOX,
|
|
||||||
"color": _Color("yellow"),
|
|
||||||
}
|
|
||||||
ERROR = {
|
|
||||||
"decoration": _Decoration.BOX,
|
|
||||||
"color": _Color("red"),
|
|
||||||
"box_title": str(MsgText.Error),
|
|
||||||
}
|
|
||||||
ERROR_ON_NEW_LINE = {
|
|
||||||
"decoration": _Decoration.BOX,
|
|
||||||
"color": _Color("red"),
|
|
||||||
"prepend_newline": True,
|
|
||||||
"box_title": str(MsgText.Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def decoration(self) -> _Decoration:
|
|
||||||
return self.value["decoration"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def color(self) -> _Color:
|
|
||||||
return self.value["color"].color
|
|
||||||
|
|
||||||
@property
|
|
||||||
def prepend_newline(self) -> bool:
|
|
||||||
return self.value.get("prepend_newline", False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def append_space(self) -> bool:
|
|
||||||
return self.value.get("append_space", False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def box_title(self) -> MsgText:
|
|
||||||
return self.value.get("box_title", None)
|
|
|
@ -1,286 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class MsgText(Enum):
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
# -- Welcome --- #
|
|
||||||
WelcomeToJrnl = """
|
|
||||||
Welcome to jrnl {version}!
|
|
||||||
|
|
||||||
It looks like you've been using an older version of jrnl until now. That's
|
|
||||||
okay - jrnl will now upgrade your configuration and journal files. Afterwards
|
|
||||||
you can enjoy all of the great new features that come with jrnl 2:
|
|
||||||
|
|
||||||
- Support for storing your journal in multiple files
|
|
||||||
- Faster reading and writing for large journals
|
|
||||||
- New encryption back-end that makes installing jrnl much easier
|
|
||||||
- Tons of bug fixes
|
|
||||||
|
|
||||||
Please note that jrnl 1.x is NOT forward compatible with this version of jrnl.
|
|
||||||
If you choose to proceed, you will not be able to use your journals with
|
|
||||||
older versions of jrnl anymore.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AllDoneUpgrade = "We're all done here and you can start enjoying jrnl 2"
|
|
||||||
|
|
||||||
InstallComplete = """
|
|
||||||
jrnl configuration created at {config_path}
|
|
||||||
For advanced features, read the docs at https://jrnl.sh
|
|
||||||
"""
|
|
||||||
|
|
||||||
# --- Prompts --- #
|
|
||||||
InstallJournalPathQuestion = """
|
|
||||||
Path to your journal file (leave blank for {default_journal_path}):
|
|
||||||
"""
|
|
||||||
DeleteEntryQuestion = "Delete entry '{entry_title}'?"
|
|
||||||
ChangeTimeEntryQuestion = "Change time for '{entry_title}'?"
|
|
||||||
EncryptJournalQuestion = """
|
|
||||||
Do you want to encrypt your journal? (You can always change this later)
|
|
||||||
"""
|
|
||||||
UseColorsQuestion = """
|
|
||||||
Do you want jrnl to use colors when displaying entries? (You can always change this later)
|
|
||||||
"""
|
|
||||||
YesOrNoPromptDefaultYes = "[Y/n]"
|
|
||||||
YesOrNoPromptDefaultNo = "[y/N]"
|
|
||||||
ContinueUpgrade = "Continue upgrading jrnl?"
|
|
||||||
|
|
||||||
# these should be lowercase, if possible in language
|
|
||||||
# "lowercase" means whatever `.lower()` returns
|
|
||||||
OneCharacterYes = "y"
|
|
||||||
OneCharacterNo = "n"
|
|
||||||
|
|
||||||
# --- Exceptions ---#
|
|
||||||
Error = "Error"
|
|
||||||
UncaughtException = """
|
|
||||||
{name}
|
|
||||||
{exception}
|
|
||||||
|
|
||||||
This is probably a bug. Please file an issue at:
|
|
||||||
https://github.com/jrnl-org/jrnl/issues/new/choose
|
|
||||||
"""
|
|
||||||
|
|
||||||
ConfigDirectoryIsFile = """
|
|
||||||
Problem with config file!
|
|
||||||
The path to your jrnl configuration directory is a file, not a directory:
|
|
||||||
|
|
||||||
{config_directory_path}
|
|
||||||
|
|
||||||
Removing this file will allow jrnl to save its configuration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
CantParseConfigFile = """
|
|
||||||
Unable to parse config file at:
|
|
||||||
{config_path}
|
|
||||||
"""
|
|
||||||
|
|
||||||
LineWrapTooSmallForDateFormat = """
|
|
||||||
The provided linewrap value of {config_linewrap} is too small by
|
|
||||||
{columns} columns to display the timestamps in the configured time
|
|
||||||
format for journal {journal}.
|
|
||||||
|
|
||||||
You can avoid this error by specifying a linewrap value that is larger
|
|
||||||
by at least {columns} in the configuration file or by using
|
|
||||||
--config-override at the command line
|
|
||||||
"""
|
|
||||||
|
|
||||||
CannotEncryptJournalType = """
|
|
||||||
The journal {journal_name} can't be encrypted because it is a
|
|
||||||
{journal_type} journal.
|
|
||||||
|
|
||||||
To encrypt it, create a new journal referencing a file, export
|
|
||||||
this journal to the new journal, then encrypt the new journal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ConfigEncryptedForUnencryptableJournalType = """
|
|
||||||
The config for journal "{journal_name}" has 'encrypt' set to true, but this type
|
|
||||||
of journal can't be encrypted. Please fix your config file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DecryptionFailedGeneric = "The decryption of journal data failed."
|
|
||||||
|
|
||||||
KeyboardInterruptMsg = "Aborted by user"
|
|
||||||
|
|
||||||
CantReadTemplate = """
|
|
||||||
Unable to find a template file {template_path}.
|
|
||||||
|
|
||||||
The following paths were checked:
|
|
||||||
* {jrnl_template_dir}{template_path}
|
|
||||||
* {actual_template_path}
|
|
||||||
"""
|
|
||||||
|
|
||||||
NoNamedJournal = "No '{journal_name}' journal configured\n{journals}"
|
|
||||||
|
|
||||||
DoesNotExist = "{name} does not exist"
|
|
||||||
|
|
||||||
# --- Journal status ---#
|
|
||||||
JournalNotSaved = "Entry NOT saved to journal"
|
|
||||||
JournalEntryAdded = "Entry added to {journal_name} journal"
|
|
||||||
|
|
||||||
JournalCountAddedSingular = "{num} entry added"
|
|
||||||
JournalCountModifiedSingular = "{num} entry modified"
|
|
||||||
JournalCountDeletedSingular = "{num} entry deleted"
|
|
||||||
|
|
||||||
JournalCountAddedPlural = "{num} entries added"
|
|
||||||
JournalCountModifiedPlural = "{num} entries modified"
|
|
||||||
JournalCountDeletedPlural = "{num} entries deleted"
|
|
||||||
|
|
||||||
JournalCreated = "Journal '{journal_name}' created at {filename}"
|
|
||||||
DirectoryCreated = "Directory {directory_name} created"
|
|
||||||
JournalEncrypted = "Journal will be encrypted"
|
|
||||||
JournalEncryptedTo = "Journal encrypted to {path}"
|
|
||||||
JournalDecryptedTo = "Journal decrypted to {path}"
|
|
||||||
BackupCreated = "Created a backup at {filename}"
|
|
||||||
|
|
||||||
# --- Editor ---#
|
|
||||||
WritingEntryStart = """
|
|
||||||
Writing Entry
|
|
||||||
To finish writing, press {how_to_quit} on a blank line.
|
|
||||||
"""
|
|
||||||
HowToQuitWindows = "Ctrl+z and then Enter"
|
|
||||||
HowToQuitLinux = "Ctrl+d"
|
|
||||||
|
|
||||||
EditorMisconfigured = """
|
|
||||||
No such file or directory: '{editor_key}'
|
|
||||||
|
|
||||||
Please check the 'editor' key in your config file for errors:
|
|
||||||
editor: '{editor_key}'
|
|
||||||
"""
|
|
||||||
|
|
||||||
EditorNotConfigured = """
|
|
||||||
There is no editor configured
|
|
||||||
|
|
||||||
To use the --edit option, please specify an editor your config file:
|
|
||||||
{config_file}
|
|
||||||
|
|
||||||
For examples of how to configure an external editor, see:
|
|
||||||
https://jrnl.sh/en/stable/external-editors/
|
|
||||||
"""
|
|
||||||
|
|
||||||
NoEditsReceivedJournalNotDeleted = """
|
|
||||||
No text received from editor. Were you trying to delete all the entries?
|
|
||||||
|
|
||||||
This seems a bit drastic, so the operation was cancelled.
|
|
||||||
|
|
||||||
To delete all entries, use the --delete option.
|
|
||||||
"""
|
|
||||||
|
|
||||||
NoEditsReceived = "No edits to save, because nothing was changed"
|
|
||||||
|
|
||||||
NoTextReceived = """
|
|
||||||
No entry to save, because no text was received
|
|
||||||
"""
|
|
||||||
NoChangesToTemplate = """
|
|
||||||
No entry to save, because the template was not changed
|
|
||||||
"""
|
|
||||||
# --- Upgrade --- #
|
|
||||||
JournalFailedUpgrade = """
|
|
||||||
The following journal{s} failed to upgrade:
|
|
||||||
{failed_journals}
|
|
||||||
|
|
||||||
Please tell us about this problem at the following URL:
|
|
||||||
https://github.com/jrnl-org/jrnl/issues/new?title=JournalFailedUpgrade
|
|
||||||
"""
|
|
||||||
|
|
||||||
UpgradeAborted = "jrnl was NOT upgraded"
|
|
||||||
|
|
||||||
AbortingUpgrade = "Aborting upgrade..."
|
|
||||||
|
|
||||||
ImportAborted = "Entries were NOT imported"
|
|
||||||
|
|
||||||
JournalsToUpgrade = """
|
|
||||||
The following journals will be upgraded to jrnl {version}:
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
JournalsToIgnore = """
|
|
||||||
The following journals will not be touched:
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
UpgradingJournal = """
|
|
||||||
Upgrading '{journal_name}' journal stored in {path}...
|
|
||||||
"""
|
|
||||||
|
|
||||||
UpgradingConfig = "Upgrading config..."
|
|
||||||
|
|
||||||
PaddedJournalName = "{journal_name:{pad}} -> {path}"
|
|
||||||
|
|
||||||
# -- Config --- #
|
|
||||||
AltConfigNotFound = """
|
|
||||||
Alternate configuration file not found at the given path:
|
|
||||||
{config_file}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ConfigUpdated = """
|
|
||||||
Configuration updated to newest version at {config_path}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ConfigDoubleKeys = """
|
|
||||||
There is at least one duplicate key in your configuration file.
|
|
||||||
|
|
||||||
Details:
|
|
||||||
{error_message}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# --- Password --- #
|
|
||||||
Password = "Password:"
|
|
||||||
PasswordFirstEntry = "Enter password for journal '{journal_name}': "
|
|
||||||
PasswordConfirmEntry = "Enter password again: "
|
|
||||||
PasswordMaxTriesExceeded = "Too many attempts with wrong password"
|
|
||||||
PasswordCanNotBeEmpty = "Password can't be empty!"
|
|
||||||
PasswordDidNotMatch = "Passwords did not match, please try again"
|
|
||||||
WrongPasswordTryAgain = "Wrong password, try again"
|
|
||||||
PasswordStoreInKeychain = "Do you want to store the password in your keychain?"
|
|
||||||
|
|
||||||
# --- Search --- #
|
|
||||||
NothingToDelete = """
|
|
||||||
No entries to delete, because the search returned no results
|
|
||||||
"""
|
|
||||||
|
|
||||||
NothingToModify = """
|
|
||||||
No entries to modify, because the search returned no results
|
|
||||||
"""
|
|
||||||
|
|
||||||
NoEntriesFound = "no entries found"
|
|
||||||
EntryFoundCountSingular = "{num} entry found"
|
|
||||||
EntryFoundCountPlural = "{num} entries found"
|
|
||||||
|
|
||||||
# --- Formats --- #
|
|
||||||
HeadingsPastH6 = """
|
|
||||||
Headings increased past H6 on export - {date} {title}
|
|
||||||
"""
|
|
||||||
|
|
||||||
YamlMustBeDirectory = """
|
|
||||||
YAML export must be to a directory, not a single file
|
|
||||||
"""
|
|
||||||
|
|
||||||
JournalExportedTo = "Journal exported to {path}"
|
|
||||||
|
|
||||||
# --- Import --- #
|
|
||||||
ImportSummary = """
|
|
||||||
{count} imported to {journal_name} journal
|
|
||||||
"""
|
|
||||||
|
|
||||||
# --- Color --- #
|
|
||||||
InvalidColor = "{key} set to invalid color: {color}"
|
|
||||||
|
|
||||||
# --- Keyring --- #
|
|
||||||
KeyringBackendNotFound = """
|
|
||||||
Keyring backend not found.
|
|
||||||
|
|
||||||
Please install one of the supported backends by visiting:
|
|
||||||
https://pypi.org/project/keyring/
|
|
||||||
"""
|
|
||||||
|
|
||||||
KeyringRetrievalFailure = "Failed to retrieve keyring"
|
|
||||||
|
|
||||||
# --- Deprecation --- #
|
|
||||||
DeprecatedCommand = """
|
|
||||||
The command {old_cmd} is deprecated and will be removed from jrnl soon.
|
|
||||||
Please use {new_cmd} instead.
|
|
||||||
"""
|
|
|
@ -1,10 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
|
|
||||||
Message = Message.Message
|
|
||||||
MsgStyle = MsgStyle.MsgStyle
|
|
||||||
MsgText = MsgText.MsgText
|
|
|
@ -1,18 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import shlex
|
|
||||||
from sys import platform
|
|
||||||
|
|
||||||
|
|
||||||
def on_windows() -> bool:
|
|
||||||
return "win32" in platform
|
|
||||||
|
|
||||||
|
|
||||||
def on_posix() -> bool:
|
|
||||||
return not on_windows()
|
|
||||||
|
|
||||||
|
|
||||||
def split_args(args: str) -> list[str]:
|
|
||||||
"""Split arguments and add escape characters as appropriate for the OS"""
|
|
||||||
return shlex.split(args, posix=on_posix())
|
|
130
jrnl/output.py
130
jrnl/output.py
|
@ -1,130 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import textwrap
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.text import Text
|
|
||||||
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
|
|
||||||
|
|
||||||
def deprecated_cmd(
|
|
||||||
old_cmd: str, new_cmd: str, callback: Callable | None = None, **kwargs
|
|
||||||
) -> None:
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.DeprecatedCommand,
|
|
||||||
MsgStyle.WARNING,
|
|
||||||
{"old_cmd": old_cmd, "new_cmd": new_cmd},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if callback is not None:
|
|
||||||
callback(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def journal_list_to_json(journal_list: dict) -> str:
|
|
||||||
import json
|
|
||||||
|
|
||||||
return json.dumps(journal_list)
|
|
||||||
|
|
||||||
|
|
||||||
def journal_list_to_yaml(journal_list: dict) -> str:
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
|
|
||||||
output = StringIO()
|
|
||||||
YAML().dump(journal_list, output)
|
|
||||||
return output.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def journal_list_to_stdout(journal_list: dict) -> str:
|
|
||||||
result = f"Journals defined in config ({journal_list['config_path']})\n"
|
|
||||||
ml = min(max(len(k) for k in journal_list["journals"]), 20)
|
|
||||||
for journal, cfg in journal_list["journals"].items():
|
|
||||||
result += " * {:{}} -> {}\n".format(
|
|
||||||
journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def list_journals(configuration: dict, format: str | None = None) -> str:
|
|
||||||
from jrnl import config
|
|
||||||
|
|
||||||
"""List the journals specified in the configuration file"""
|
|
||||||
|
|
||||||
journal_list = {
|
|
||||||
"config_path": config.get_config_path(),
|
|
||||||
"journals": configuration["journals"],
|
|
||||||
}
|
|
||||||
|
|
||||||
if format == "json":
|
|
||||||
return journal_list_to_json(journal_list)
|
|
||||||
elif format == "yaml":
|
|
||||||
return journal_list_to_yaml(journal_list)
|
|
||||||
else:
|
|
||||||
return journal_list_to_stdout(journal_list)
|
|
||||||
|
|
||||||
|
|
||||||
def print_msg(msg: Message, **kwargs) -> str | None:
|
|
||||||
"""Helper function to print a single message"""
|
|
||||||
kwargs["style"] = msg.style
|
|
||||||
return print_msgs([msg], **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def print_msgs(
|
|
||||||
msgs: list[Message],
|
|
||||||
delimiter: str = "\n",
|
|
||||||
style: MsgStyle = MsgStyle.NORMAL,
|
|
||||||
get_input: bool = False,
|
|
||||||
hide_input: bool = False,
|
|
||||||
) -> str | None:
|
|
||||||
# Same as print_msg, but for a list
|
|
||||||
text = Text("", end="")
|
|
||||||
kwargs = style.decoration.args
|
|
||||||
|
|
||||||
for i, msg in enumerate(msgs):
|
|
||||||
kwargs = _add_extra_style_args_if_needed(kwargs, msg=msg)
|
|
||||||
|
|
||||||
m = format_msg_text(msg)
|
|
||||||
|
|
||||||
if i != len(msgs) - 1:
|
|
||||||
m.append(delimiter)
|
|
||||||
|
|
||||||
text.append(m)
|
|
||||||
|
|
||||||
if style.append_space:
|
|
||||||
text.append(" ")
|
|
||||||
|
|
||||||
decorated_text = style.decoration.callback(text, **kwargs)
|
|
||||||
|
|
||||||
# Always print messages to stderr
|
|
||||||
console = _get_console(stderr=True)
|
|
||||||
|
|
||||||
if get_input:
|
|
||||||
return str(console.input(prompt=decorated_text, password=hide_input))
|
|
||||||
console.print(decorated_text, new_line_start=style.prepend_newline)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_console(stderr: bool = True) -> Console:
|
|
||||||
return Console(stderr=stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def _add_extra_style_args_if_needed(args: dict, msg: Message):
|
|
||||||
args["border_style"] = msg.style.color
|
|
||||||
args["title"] = msg.style.box_title
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def format_msg_text(msg: Message) -> Text:
|
|
||||||
text = textwrap.dedent(msg.text.value)
|
|
||||||
text = text.format(**msg.params)
|
|
||||||
# dedent again in case inserted text needs it
|
|
||||||
text = textwrap.dedent(text)
|
|
||||||
text = text.strip()
|
|
||||||
return Text(text)
|
|
|
@ -1,78 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.config import make_yaml_valid_dict
|
|
||||||
from jrnl.config import update_config
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
|
|
||||||
# import logging
|
|
||||||
def apply_overrides(args: "Namespace", base_config: dict) -> dict:
|
|
||||||
"""Unpack CLI provided overrides into the configuration tree.
|
|
||||||
|
|
||||||
:param overrides: List of configuration key-value pairs collected from the CLI
|
|
||||||
:type overrides: list
|
|
||||||
:param base_config: Configuration Loaded from the saved YAML
|
|
||||||
:type base_config: dict
|
|
||||||
:return: Configuration to be used during runtime with the overrides applied
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
overrides = vars(args).get("config_override", None)
|
|
||||||
if not overrides:
|
|
||||||
return base_config
|
|
||||||
|
|
||||||
cfg_with_overrides = base_config.copy()
|
|
||||||
for pairs in overrides:
|
|
||||||
pairs = make_yaml_valid_dict(pairs)
|
|
||||||
key_as_dots, override_value = _get_key_and_value_from_pair(pairs)
|
|
||||||
keys = _convert_dots_to_list(key_as_dots)
|
|
||||||
cfg_with_overrides = _recursively_apply(
|
|
||||||
cfg_with_overrides, keys, override_value
|
|
||||||
)
|
|
||||||
|
|
||||||
update_config(base_config, cfg_with_overrides, None)
|
|
||||||
return base_config
|
|
||||||
|
|
||||||
|
|
||||||
def _get_key_and_value_from_pair(pairs: dict) -> tuple:
|
|
||||||
key_as_dots, override_value = list(pairs.items())[0]
|
|
||||||
return key_as_dots, override_value
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_dots_to_list(key_as_dots: str) -> list[str]:
|
|
||||||
keys = key_as_dots.split(".")
|
|
||||||
keys = [k for k in keys if k != ""] # remove empty elements
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def _recursively_apply(tree: dict, nodes: list, override_value) -> dict:
|
|
||||||
"""Recurse through configuration and apply overrides at the leaf of the config tree
|
|
||||||
|
|
||||||
Credit to iJames on SO: https://stackoverflow.com/a/47276490 for algorithm
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config (dict): Configuration to modify
|
|
||||||
nodes (list): Vector of override keys; the length of the vector indicates tree depth
|
|
||||||
override_value (str): Runtime override passed from the command-line
|
|
||||||
"""
|
|
||||||
key = nodes[0]
|
|
||||||
if len(nodes) == 1:
|
|
||||||
tree[key] = override_value
|
|
||||||
else:
|
|
||||||
next_key = nodes[1:]
|
|
||||||
next_node = _get_config_node(tree, key)
|
|
||||||
_recursively_apply(next_node, next_key, override_value)
|
|
||||||
|
|
||||||
return tree
|
|
||||||
|
|
||||||
|
|
||||||
def _get_config_node(config: dict, key: str):
|
|
||||||
if key in config:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
config[key] = None
|
|
||||||
return config[key]
|
|
72
jrnl/path.py
72
jrnl/path.py
|
@ -1,72 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import os.path
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import xdg.BaseDirectory
|
|
||||||
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
|
|
||||||
# Constants
|
|
||||||
XDG_RESOURCE = "jrnl"
|
|
||||||
DEFAULT_CONFIG_NAME = "jrnl.yaml"
|
|
||||||
DEFAULT_JOURNAL_NAME = "journal.txt"
|
|
||||||
|
|
||||||
|
|
||||||
def home_dir() -> str:
|
|
||||||
return os.path.expanduser("~")
|
|
||||||
|
|
||||||
|
|
||||||
def expand_path(path: str) -> str:
|
|
||||||
return os.path.expanduser(os.path.expandvars(path))
|
|
||||||
|
|
||||||
|
|
||||||
def absolute_path(path: str) -> str:
|
|
||||||
return os.path.abspath(expand_path(path))
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_journal_path() -> str:
|
|
||||||
journal_data_path = xdg.BaseDirectory.save_data_path(XDG_RESOURCE) or home_dir()
|
|
||||||
return os.path.join(journal_data_path, DEFAULT_JOURNAL_NAME)
|
|
||||||
|
|
||||||
|
|
||||||
def get_templates_path() -> str:
|
|
||||||
"""
|
|
||||||
Get the path to the XDG templates directory. Creates the directory if it
|
|
||||||
doesn't exist.
|
|
||||||
"""
|
|
||||||
# jrnl_xdg_resource_path is created by save_data_path if it does not exist
|
|
||||||
jrnl_xdg_resource_path = Path(xdg.BaseDirectory.save_data_path(XDG_RESOURCE))
|
|
||||||
jrnl_templates_path = jrnl_xdg_resource_path / "templates"
|
|
||||||
# Create the directory if needed.
|
|
||||||
jrnl_templates_path.mkdir(exist_ok=True)
|
|
||||||
return str(jrnl_templates_path)
|
|
||||||
|
|
||||||
|
|
||||||
def get_config_directory() -> str:
|
|
||||||
try:
|
|
||||||
return xdg.BaseDirectory.save_config_path(XDG_RESOURCE)
|
|
||||||
except FileExistsError:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.ConfigDirectoryIsFile,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{
|
|
||||||
"config_directory_path": os.path.join(
|
|
||||||
xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_config_path() -> str:
|
|
||||||
try:
|
|
||||||
config_directory_path = get_config_directory()
|
|
||||||
except JrnlException:
|
|
||||||
return os.path.join(home_dir(), DEFAULT_CONFIG_NAME)
|
|
||||||
return os.path.join(config_directory_path, DEFAULT_CONFIG_NAME)
|
|
|
@ -1,48 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from typing import Type
|
|
||||||
|
|
||||||
from jrnl.plugins.dates_exporter import DatesExporter
|
|
||||||
from jrnl.plugins.fancy_exporter import FancyExporter
|
|
||||||
from jrnl.plugins.jrnl_importer import JRNLImporter
|
|
||||||
from jrnl.plugins.json_exporter import JSONExporter
|
|
||||||
from jrnl.plugins.markdown_exporter import MarkdownExporter
|
|
||||||
from jrnl.plugins.tag_exporter import TagExporter
|
|
||||||
from jrnl.plugins.text_exporter import TextExporter
|
|
||||||
from jrnl.plugins.xml_exporter import XMLExporter
|
|
||||||
from jrnl.plugins.yaml_exporter import YAMLExporter
|
|
||||||
|
|
||||||
__exporters = [
|
|
||||||
JSONExporter,
|
|
||||||
MarkdownExporter,
|
|
||||||
TagExporter,
|
|
||||||
DatesExporter,
|
|
||||||
TextExporter,
|
|
||||||
XMLExporter,
|
|
||||||
YAMLExporter,
|
|
||||||
FancyExporter,
|
|
||||||
]
|
|
||||||
__importers = [JRNLImporter]
|
|
||||||
|
|
||||||
__exporter_types = {name: plugin for plugin in __exporters for name in plugin.names}
|
|
||||||
__exporter_types["pretty"] = None
|
|
||||||
__exporter_types["short"] = None
|
|
||||||
__importer_types = {name: plugin for plugin in __importers for name in plugin.names}
|
|
||||||
|
|
||||||
EXPORT_FORMATS = sorted(__exporter_types.keys())
|
|
||||||
IMPORT_FORMATS = sorted(__importer_types.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def get_exporter(format: str) -> Type[TextExporter] | None:
|
|
||||||
for exporter in __exporters:
|
|
||||||
if hasattr(exporter, "names") and format in exporter.names:
|
|
||||||
return exporter
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_importer(format: str) -> Type[JRNLImporter] | None:
|
|
||||||
for importer in __importers:
|
|
||||||
if hasattr(importer, "names") and format in importer.names:
|
|
||||||
return importer
|
|
||||||
return None
|
|
|
@ -1,33 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from collections import Counter
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.plugins.text_exporter import TextExporter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class DatesExporter(TextExporter):
|
|
||||||
"""This Exporter lists dates and their respective counts, for heatingmapping etc."""
|
|
||||||
|
|
||||||
names = ["dates"]
|
|
||||||
extension = "dates"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(cls, entry: "Entry"):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal: "Journal") -> str:
|
|
||||||
"""Returns dates and their frequencies for an entire journal."""
|
|
||||||
date_counts = Counter()
|
|
||||||
for entry in journal.entries:
|
|
||||||
# entry.date.date() gets date without time
|
|
||||||
date = str(entry.date.date())
|
|
||||||
date_counts[date] += 1
|
|
||||||
result = "\n".join(f"{date}, {count}" for date, count in date_counts.items())
|
|
||||||
return result
|
|
|
@ -1,123 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from textwrap import TextWrapper
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.plugins.text_exporter import TextExporter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class FancyExporter(TextExporter):
|
|
||||||
"""This Exporter can convert entries and journals into text with unicode box drawing characters."""
|
|
||||||
|
|
||||||
names = ["fancy", "boxed"]
|
|
||||||
extension = "txt"
|
|
||||||
|
|
||||||
# Top border of the card
|
|
||||||
border_a = "┎"
|
|
||||||
border_b = "─"
|
|
||||||
border_c = "╮"
|
|
||||||
border_d = "╘"
|
|
||||||
border_e = "═"
|
|
||||||
border_f = "╕"
|
|
||||||
|
|
||||||
border_g = "┃"
|
|
||||||
border_h = "│"
|
|
||||||
border_i = "┠"
|
|
||||||
border_j = "╌"
|
|
||||||
border_k = "┤"
|
|
||||||
border_l = "┖"
|
|
||||||
border_m = "┘"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(cls, entry: "Entry") -> str:
|
|
||||||
"""Returns a fancy unicode representation of a single entry."""
|
|
||||||
date_str = entry.date.strftime(entry.journal.config["timeformat"])
|
|
||||||
|
|
||||||
if entry.journal.config["linewrap"]:
|
|
||||||
linewrap = entry.journal.config["linewrap"]
|
|
||||||
|
|
||||||
if linewrap == "auto":
|
|
||||||
try:
|
|
||||||
linewrap = os.get_terminal_size().columns
|
|
||||||
except OSError:
|
|
||||||
logging.debug(
|
|
||||||
"Can't determine terminal size automatically 'linewrap': '%s'",
|
|
||||||
entry.journal.config["linewrap"],
|
|
||||||
)
|
|
||||||
linewrap = 79
|
|
||||||
else:
|
|
||||||
linewrap = 79
|
|
||||||
|
|
||||||
initial_linewrap = max((1, linewrap - len(date_str) - 2))
|
|
||||||
body_linewrap = linewrap - 2
|
|
||||||
card = [
|
|
||||||
cls.border_a + cls.border_b * (initial_linewrap) + cls.border_c + date_str
|
|
||||||
]
|
|
||||||
check_provided_linewrap_viability(linewrap, card, entry.journal.name)
|
|
||||||
|
|
||||||
w = TextWrapper(
|
|
||||||
width=initial_linewrap,
|
|
||||||
initial_indent=cls.border_g + " ",
|
|
||||||
subsequent_indent=cls.border_g + " ",
|
|
||||||
)
|
|
||||||
|
|
||||||
title_lines = w.wrap(entry.title) or [""]
|
|
||||||
card.append(
|
|
||||||
title_lines[0].ljust(initial_linewrap + 1)
|
|
||||||
+ cls.border_d
|
|
||||||
+ cls.border_e * (len(date_str) - 1)
|
|
||||||
+ cls.border_f
|
|
||||||
)
|
|
||||||
w.width = body_linewrap
|
|
||||||
if len(title_lines) > 1:
|
|
||||||
for line in w.wrap(
|
|
||||||
" ".join(
|
|
||||||
[
|
|
||||||
title_line[len(w.subsequent_indent) :]
|
|
||||||
for title_line in title_lines[1:]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
):
|
|
||||||
card.append(line.ljust(body_linewrap + 1) + cls.border_h)
|
|
||||||
if entry.body:
|
|
||||||
card.append(cls.border_i + cls.border_j * body_linewrap + cls.border_k)
|
|
||||||
for line in entry.body.splitlines():
|
|
||||||
body_lines = w.wrap(line) or [cls.border_g]
|
|
||||||
for body_line in body_lines:
|
|
||||||
card.append(body_line.ljust(body_linewrap + 1) + cls.border_h)
|
|
||||||
card.append(cls.border_l + cls.border_b * body_linewrap + cls.border_m)
|
|
||||||
return "\n".join(card)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal) -> str:
|
|
||||||
"""Returns a unicode representation of an entire journal."""
|
|
||||||
return "\n".join(cls.export_entry(entry) for entry in journal)
|
|
||||||
|
|
||||||
|
|
||||||
def check_provided_linewrap_viability(
|
|
||||||
linewrap: int, card: list[str], journal: "Journal"
|
|
||||||
):
|
|
||||||
if len(card[0]) > linewrap:
|
|
||||||
width_violation = len(card[0]) - linewrap
|
|
||||||
raise JrnlException(
|
|
||||||
Message(
|
|
||||||
MsgText.LineWrapTooSmallForDateFormat,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{
|
|
||||||
"config_linewrap": linewrap,
|
|
||||||
"columns": width_violation,
|
|
||||||
"journal": journal,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
|
@ -1,51 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class JRNLImporter:
|
|
||||||
"""This plugin imports entries from other jrnl files."""
|
|
||||||
|
|
||||||
names = ["jrnl"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def import_(journal: "Journal", input: str | None = None) -> None:
|
|
||||||
"""Imports from an existing file if input is specified, and
|
|
||||||
standard input otherwise."""
|
|
||||||
old_cnt = len(journal.entries)
|
|
||||||
if input:
|
|
||||||
with open(input, "r", encoding="utf-8") as f:
|
|
||||||
other_journal_txt = f.read()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
other_journal_txt = sys.stdin.read()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(MsgText.KeyboardInterruptMsg, MsgStyle.ERROR_ON_NEW_LINE),
|
|
||||||
Message(MsgText.ImportAborted, MsgStyle.WARNING),
|
|
||||||
)
|
|
||||||
|
|
||||||
journal.import_(other_journal_txt)
|
|
||||||
new_cnt = len(journal.entries)
|
|
||||||
journal.write()
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.ImportSummary,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{
|
|
||||||
"count": new_cnt - old_cnt,
|
|
||||||
"journal_name": journal.name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
|
@ -1,69 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.plugins.text_exporter import TextExporter
|
|
||||||
from jrnl.plugins.util import get_tags_count
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class JSONExporter(TextExporter):
|
|
||||||
"""This Exporter can convert entries and journals into json."""
|
|
||||||
|
|
||||||
names = ["json"]
|
|
||||||
extension = "json"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def entry_to_dict(cls, entry: "Entry") -> dict:
|
|
||||||
entry_dict = {
|
|
||||||
"title": entry.title,
|
|
||||||
"body": entry.body,
|
|
||||||
"date": entry.date.strftime("%Y-%m-%d"),
|
|
||||||
"time": entry.date.strftime("%H:%M"),
|
|
||||||
"tags": entry.tags,
|
|
||||||
"starred": entry.starred,
|
|
||||||
}
|
|
||||||
if hasattr(entry, "uuid"):
|
|
||||||
entry_dict["uuid"] = entry.uuid
|
|
||||||
if (
|
|
||||||
hasattr(entry, "creator_device_agent")
|
|
||||||
or hasattr(entry, "creator_generation_date")
|
|
||||||
or hasattr(entry, "creator_host_name")
|
|
||||||
or hasattr(entry, "creator_os_agent")
|
|
||||||
or hasattr(entry, "creator_software_agent")
|
|
||||||
):
|
|
||||||
entry_dict["creator"] = {}
|
|
||||||
if hasattr(entry, "creator_device_agent"):
|
|
||||||
entry_dict["creator"]["device_agent"] = entry.creator_device_agent
|
|
||||||
if hasattr(entry, "creator_generation_date"):
|
|
||||||
entry_dict["creator"]["generation_date"] = str(
|
|
||||||
entry.creator_generation_date
|
|
||||||
)
|
|
||||||
if hasattr(entry, "creator_host_name"):
|
|
||||||
entry_dict["creator"]["host_name"] = entry.creator_host_name
|
|
||||||
if hasattr(entry, "creator_os_agent"):
|
|
||||||
entry_dict["creator"]["os_agent"] = entry.creator_os_agent
|
|
||||||
if hasattr(entry, "creator_software_agent"):
|
|
||||||
entry_dict["creator"]["software_agent"] = entry.creator_software_agent
|
|
||||||
|
|
||||||
return entry_dict
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(cls, entry: "Entry") -> str:
|
|
||||||
"""Returns a json representation of a single entry."""
|
|
||||||
return json.dumps(cls.entry_to_dict(entry), indent=2) + "\n"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal: "Journal") -> str:
|
|
||||||
"""Returns a json representation of an entire journal."""
|
|
||||||
tags = get_tags_count(journal)
|
|
||||||
result = {
|
|
||||||
"tags": {tag: count for count, tag in tags},
|
|
||||||
"entries": [cls.entry_to_dict(e) for e in journal.entries],
|
|
||||||
}
|
|
||||||
return json.dumps(result, indent=2)
|
|
|
@ -1,96 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.plugins.text_exporter import TextExporter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownExporter(TextExporter):
|
|
||||||
"""This Exporter can convert entries and journals into Markdown."""
|
|
||||||
|
|
||||||
names = ["md", "markdown"]
|
|
||||||
extension = "md"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(cls, entry: "Entry", to_multifile: bool = True) -> str:
|
|
||||||
"""Returns a markdown representation of a single entry."""
|
|
||||||
date_str = entry.date.strftime(entry.journal.config["timeformat"])
|
|
||||||
body_wrapper = "\n" if entry.body else ""
|
|
||||||
body = body_wrapper + entry.body
|
|
||||||
|
|
||||||
if to_multifile is True:
|
|
||||||
heading = "#"
|
|
||||||
else:
|
|
||||||
heading = "###"
|
|
||||||
|
|
||||||
"""Increase heading levels in body text"""
|
|
||||||
newbody = ""
|
|
||||||
previous_line = ""
|
|
||||||
warn_on_heading_level = False
|
|
||||||
for line in body.splitlines(True):
|
|
||||||
if re.match(r"^#+ ", line):
|
|
||||||
"""ATX style headings"""
|
|
||||||
newbody = newbody + previous_line + heading + line
|
|
||||||
if re.match(r"^#######+ ", heading + line):
|
|
||||||
warn_on_heading_level = True
|
|
||||||
line = ""
|
|
||||||
elif re.match(r"^=+$", line.rstrip()) and not re.match(
|
|
||||||
r"^$", previous_line.strip()
|
|
||||||
):
|
|
||||||
"""Setext style H1"""
|
|
||||||
newbody = newbody + heading + "# " + previous_line
|
|
||||||
line = ""
|
|
||||||
elif re.match(r"^-+$", line.rstrip()) and not re.match(
|
|
||||||
r"^$", previous_line.strip()
|
|
||||||
):
|
|
||||||
"""Setext style H2"""
|
|
||||||
newbody = newbody + heading + "## " + previous_line
|
|
||||||
line = ""
|
|
||||||
else:
|
|
||||||
newbody = newbody + previous_line
|
|
||||||
previous_line = line
|
|
||||||
newbody = newbody + previous_line # add very last line
|
|
||||||
|
|
||||||
# make sure the export ends with a blank line
|
|
||||||
if previous_line not in ["\r", "\n", "\r\n", "\n\r"]:
|
|
||||||
newbody = newbody + os.linesep
|
|
||||||
|
|
||||||
if warn_on_heading_level is True:
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.HeadingsPastH6,
|
|
||||||
MsgStyle.WARNING,
|
|
||||||
{"date": date_str, "title": entry.title},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"{heading} {date_str} {entry.title}\n{newbody} "
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal: "Journal") -> str:
|
|
||||||
"""Returns a Markdown representation of an entire journal."""
|
|
||||||
out = []
|
|
||||||
year, month = -1, -1
|
|
||||||
for e in journal.entries:
|
|
||||||
if e.date.year != year:
|
|
||||||
year = e.date.year
|
|
||||||
out.append("# " + str(year))
|
|
||||||
out.append("")
|
|
||||||
if e.date.month != month:
|
|
||||||
month = e.date.month
|
|
||||||
out.append("## " + e.date.strftime("%B"))
|
|
||||||
out.append("")
|
|
||||||
out.append(cls.export_entry(e, False))
|
|
||||||
result = "\n".join(out)
|
|
||||||
return result
|
|
|
@ -1,38 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.plugins.text_exporter import TextExporter
|
|
||||||
from jrnl.plugins.util import get_tags_count
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class TagExporter(TextExporter):
|
|
||||||
"""This Exporter can lists the tags for entries and journals, exported as a plain text file."""
|
|
||||||
|
|
||||||
names = ["tags"]
|
|
||||||
extension = "tags"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(cls, entry: "Entry") -> str:
|
|
||||||
"""Returns a list of tags for a single entry."""
|
|
||||||
return ", ".join(entry.tags)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal: "Journal") -> str:
|
|
||||||
"""Returns a list of tags and their frequency for an entire journal."""
|
|
||||||
tag_counts = get_tags_count(journal)
|
|
||||||
result = ""
|
|
||||||
if not tag_counts:
|
|
||||||
return "[No tags found in journal.]"
|
|
||||||
elif min(tag_counts)[0] == 0:
|
|
||||||
tag_counts = filter(lambda x: x[0] > 1, tag_counts)
|
|
||||||
result += "[Removed tags that appear only once.]\n"
|
|
||||||
result += "\n".join(
|
|
||||||
"{:20} : {}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True)
|
|
||||||
)
|
|
||||||
return result
|
|
|
@ -1,109 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import errno
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import unicodedata
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class TextExporter:
|
|
||||||
"""This Exporter can convert entries and journals into text files."""
|
|
||||||
|
|
||||||
names = ["text", "txt"]
|
|
||||||
extension = "txt"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(cls, entry: "Entry") -> str:
|
|
||||||
"""Returns a string representation of a single entry."""
|
|
||||||
return str(entry)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal: "Journal") -> str:
|
|
||||||
"""Returns a string representation of an entire journal."""
|
|
||||||
return "\n".join(cls.export_entry(entry) for entry in journal)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def write_file(cls, journal: "Journal", path: str) -> str:
|
|
||||||
"""Exports a journal into a single file."""
|
|
||||||
export_str = cls.export_journal(journal)
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(export_str)
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.JournalExportedTo,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{
|
|
||||||
"path": path,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def make_filename(cls, entry: "Entry") -> str:
|
|
||||||
return entry.date.strftime("%Y-%m-%d") + "_{}.{}".format(
|
|
||||||
cls._slugify(str(entry.title)), cls.extension
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def write_files(cls, journal: "Journal", path: str) -> str:
|
|
||||||
"""Exports a journal into individual files for each entry."""
|
|
||||||
for entry in journal.entries:
|
|
||||||
entry_is_written = False
|
|
||||||
while not entry_is_written:
|
|
||||||
full_path = os.path.join(path, cls.make_filename(entry))
|
|
||||||
try:
|
|
||||||
with open(full_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(cls.export_entry(entry))
|
|
||||||
entry_is_written = True
|
|
||||||
except OSError as oserr:
|
|
||||||
title_length = len(str(entry.title))
|
|
||||||
if (
|
|
||||||
oserr.errno == errno.ENAMETOOLONG
|
|
||||||
or oserr.errno == errno.ENOENT
|
|
||||||
or oserr.errno == errno.EINVAL
|
|
||||||
) and title_length > 1:
|
|
||||||
shorter_file_length = title_length // 2
|
|
||||||
entry.title = str(entry.title)[:shorter_file_length]
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.JournalExportedTo,
|
|
||||||
MsgStyle.NORMAL,
|
|
||||||
{"path": path},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _slugify(string: str) -> str:
|
|
||||||
"""Slugifies a string.
|
|
||||||
Based on public domain code from https://github.com/zacharyvoase/slugify
|
|
||||||
"""
|
|
||||||
normalized_string = str(unicodedata.normalize("NFKD", string))
|
|
||||||
no_punctuation = re.sub(r"[^\w\s-]", "", normalized_string).strip().lower()
|
|
||||||
slug = re.sub(r"[-\s]+", "-", no_punctuation)
|
|
||||||
return slug
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export(cls, journal: "Journal", output: str | None = None) -> str:
|
|
||||||
"""Exports to individual files if output is an existing path, or into
|
|
||||||
a single file if output is a file name, or returns the exporter's
|
|
||||||
representation as string if output is None."""
|
|
||||||
if output and os.path.isdir(output): # multiple files
|
|
||||||
return cls.write_files(journal, output)
|
|
||||||
elif output: # single file
|
|
||||||
return cls.write_file(journal, output)
|
|
||||||
else:
|
|
||||||
return cls.export_journal(journal)
|
|
|
@ -1,30 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
def get_tags_count(journal: "Journal") -> set[tuple[int, str]]:
|
|
||||||
"""Returns a set of tuples (count, tag) for all tags present in the journal."""
|
|
||||||
# Astute reader: should the following line leave you as puzzled as me the first time
|
|
||||||
# I came across this construction, worry not and embrace the ensuing moment of enlightment.
|
|
||||||
tags = [tag for entry in journal.entries for tag in set(entry.tags)]
|
|
||||||
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
|
|
||||||
tag_counts = {(tags.count(tag), tag) for tag in tags}
|
|
||||||
return tag_counts
|
|
||||||
|
|
||||||
|
|
||||||
def oxford_list(lst: list) -> str:
|
|
||||||
"""Return Human-readable list of things obeying the object comma)"""
|
|
||||||
lst = sorted(lst)
|
|
||||||
if not lst:
|
|
||||||
return "(nothing)"
|
|
||||||
elif len(lst) == 1:
|
|
||||||
return lst[0]
|
|
||||||
elif len(lst) == 2:
|
|
||||||
return lst[0] + " or " + lst[1]
|
|
||||||
else:
|
|
||||||
return ", ".join(lst[:-1]) + ", or " + lst[-1]
|
|
|
@ -1,72 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from xml.dom import minidom
|
|
||||||
|
|
||||||
from jrnl.plugins.json_exporter import JSONExporter
|
|
||||||
from jrnl.plugins.util import get_tags_count
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class XMLExporter(JSONExporter):
|
|
||||||
"""This Exporter can convert entries and journals into XML."""
|
|
||||||
|
|
||||||
names = ["xml"]
|
|
||||||
extension = "xml"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(
|
|
||||||
cls, entry: "Entry", doc: minidom.Document | None = None
|
|
||||||
) -> minidom.Element | str:
|
|
||||||
"""Returns an XML representation of a single entry."""
|
|
||||||
doc_el = doc or minidom.Document()
|
|
||||||
entry_el = doc_el.createElement("entry")
|
|
||||||
for key, value in cls.entry_to_dict(entry).items():
|
|
||||||
elem = doc_el.createElement(key)
|
|
||||||
elem.appendChild(doc_el.createTextNode(value))
|
|
||||||
entry_el.appendChild(elem)
|
|
||||||
if not doc:
|
|
||||||
doc_el.appendChild(entry_el)
|
|
||||||
return doc_el.toprettyxml()
|
|
||||||
else:
|
|
||||||
return entry_el
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def entry_to_xml(cls, entry: "Entry", doc: minidom.Document) -> minidom.Element:
|
|
||||||
entry_el = doc.createElement("entry")
|
|
||||||
entry_el.setAttribute("date", entry.date.isoformat())
|
|
||||||
if hasattr(entry, "uuid"):
|
|
||||||
entry_el.setAttribute("uuid", entry.uuid)
|
|
||||||
entry_el.setAttribute("starred", entry.starred)
|
|
||||||
tags = entry.tags
|
|
||||||
for tag in tags:
|
|
||||||
tag_el = doc.createElement("tag")
|
|
||||||
tag_el.setAttribute("name", tag)
|
|
||||||
entry_el.appendChild(tag_el)
|
|
||||||
entry_el.appendChild(doc.createTextNode(entry.fulltext))
|
|
||||||
return entry_el
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal: "Journal") -> str:
|
|
||||||
"""Returns an XML representation of an entire journal."""
|
|
||||||
tags = get_tags_count(journal)
|
|
||||||
doc = minidom.Document()
|
|
||||||
xml = doc.createElement("journal")
|
|
||||||
tags_el = doc.createElement("tags")
|
|
||||||
entries_el = doc.createElement("entries")
|
|
||||||
for count, tag in tags:
|
|
||||||
tag_el = doc.createElement("tag")
|
|
||||||
tag_el.setAttribute("name", tag)
|
|
||||||
count_node = doc.createTextNode(str(count))
|
|
||||||
tag_el.appendChild(count_node)
|
|
||||||
tags_el.appendChild(tag_el)
|
|
||||||
for entry in journal.entries:
|
|
||||||
entries_el.appendChild(cls.entry_to_xml(entry, doc))
|
|
||||||
xml.appendChild(entries_el)
|
|
||||||
xml.appendChild(tags_el)
|
|
||||||
doc.appendChild(xml)
|
|
||||||
return doc.toprettyxml()
|
|
|
@ -1,134 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.plugins.text_exporter import TextExporter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from jrnl.journals import Entry
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
|
|
||||||
|
|
||||||
class YAMLExporter(TextExporter):
|
|
||||||
"""This Exporter can convert entries and journals into Markdown formatted text with YAML front matter."""
|
|
||||||
|
|
||||||
names = ["yaml"]
|
|
||||||
extension = "md"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_entry(cls, entry: "Entry", to_multifile: bool = True) -> str:
|
|
||||||
"""Returns a markdown representation of a single entry, with YAML front matter."""
|
|
||||||
if to_multifile is False:
|
|
||||||
raise JrnlException(Message(MsgText.YamlMustBeDirectory, MsgStyle.ERROR))
|
|
||||||
|
|
||||||
date_str = entry.date.strftime(entry.journal.config["timeformat"])
|
|
||||||
body_wrapper = "\n" if entry.body else ""
|
|
||||||
body = body_wrapper + entry.body
|
|
||||||
|
|
||||||
tagsymbols = entry.journal.config["tagsymbols"]
|
|
||||||
# see also Entry.rag_regex
|
|
||||||
multi_tag_regex = re.compile(rf"(?u)^\s*([{tagsymbols}][-+*#/\w]+\s*)+$")
|
|
||||||
|
|
||||||
"""Increase heading levels in body text"""
|
|
||||||
newbody = ""
|
|
||||||
heading = "#"
|
|
||||||
previous_line = ""
|
|
||||||
warn_on_heading_level = False
|
|
||||||
for line in body.splitlines(True):
|
|
||||||
if re.match(r"^#+ ", line):
|
|
||||||
"""ATX style headings"""
|
|
||||||
newbody = newbody + previous_line + heading + line
|
|
||||||
if re.match(r"^#######+ ", heading + line):
|
|
||||||
warn_on_heading_level = True
|
|
||||||
line = ""
|
|
||||||
elif re.match(r"^=+$", line.rstrip()) and not re.match(
|
|
||||||
r"^$", previous_line.strip()
|
|
||||||
):
|
|
||||||
"""Setext style H1"""
|
|
||||||
newbody = newbody + heading + "# " + previous_line
|
|
||||||
line = ""
|
|
||||||
elif re.match(r"^-+$", line.rstrip()) and not re.match(
|
|
||||||
r"^$", previous_line.strip()
|
|
||||||
):
|
|
||||||
"""Setext style H2"""
|
|
||||||
newbody = newbody + heading + "## " + previous_line
|
|
||||||
line = ""
|
|
||||||
elif multi_tag_regex.match(line):
|
|
||||||
"""Tag only lines"""
|
|
||||||
line = ""
|
|
||||||
else:
|
|
||||||
newbody = newbody + previous_line
|
|
||||||
previous_line = line
|
|
||||||
newbody = newbody + previous_line # add very last line
|
|
||||||
|
|
||||||
# make sure the export ends with a blank line
|
|
||||||
if previous_line not in ["\r", "\n", "\r\n", "\n\r"]:
|
|
||||||
newbody = newbody + os.linesep
|
|
||||||
|
|
||||||
# set indentation for YAML body block
|
|
||||||
spacebody = "\t"
|
|
||||||
for line in newbody.splitlines(True):
|
|
||||||
spacebody = spacebody + "\t" + line
|
|
||||||
|
|
||||||
if warn_on_heading_level is True:
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.HeadingsPastH6,
|
|
||||||
MsgStyle.WARNING,
|
|
||||||
{"date": date_str, "title": entry.title},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dayone_attributes = ""
|
|
||||||
if hasattr(entry, "uuid"):
|
|
||||||
dayone_attributes += "uuid: " + entry.uuid + "\n"
|
|
||||||
if (
|
|
||||||
hasattr(entry, "creator_device_agent")
|
|
||||||
or hasattr(entry, "creator_generation_date")
|
|
||||||
or hasattr(entry, "creator_host_name")
|
|
||||||
or hasattr(entry, "creator_os_agent")
|
|
||||||
or hasattr(entry, "creator_software_agent")
|
|
||||||
):
|
|
||||||
dayone_attributes += "creator:\n"
|
|
||||||
if hasattr(entry, "creator_device_agent"):
|
|
||||||
dayone_attributes += f" device agent: {entry.creator_device_agent}\n"
|
|
||||||
if hasattr(entry, "creator_generation_date"):
|
|
||||||
dayone_attributes += " generation date: {}\n".format(
|
|
||||||
str(entry.creator_generation_date)
|
|
||||||
)
|
|
||||||
if hasattr(entry, "creator_host_name"):
|
|
||||||
dayone_attributes += f" host name: {entry.creator_host_name}\n"
|
|
||||||
if hasattr(entry, "creator_os_agent"):
|
|
||||||
dayone_attributes += f" os agent: {entry.creator_os_agent}\n"
|
|
||||||
if hasattr(entry, "creator_software_agent"):
|
|
||||||
dayone_attributes += (
|
|
||||||
f" software agent: {entry.creator_software_agent}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: copy over pictures, if present
|
|
||||||
# source directory is entry.journal.config['journal']
|
|
||||||
# output directory is...?
|
|
||||||
|
|
||||||
return "{start}\ntitle: {title}\ndate: {date}\nstarred: {starred}\ntags: {tags}\n{dayone}body: |{body}{end}".format(
|
|
||||||
start="---",
|
|
||||||
date=date_str,
|
|
||||||
title=entry.title,
|
|
||||||
starred=entry.starred,
|
|
||||||
tags=", ".join([tag[1:] for tag in entry.tags]),
|
|
||||||
dayone=dayone_attributes,
|
|
||||||
body=spacebody,
|
|
||||||
end="...",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def export_journal(cls, journal: "Journal"):
|
|
||||||
"""Returns an error, as YAML export requires a directory as a target."""
|
|
||||||
raise JrnlException(Message(MsgText.YamlMustBeDirectory, MsgStyle.ERROR))
|
|
|
@ -1,80 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.output import print_msgs
|
|
||||||
|
|
||||||
|
|
||||||
def create_password(journal_name: str) -> str:
|
|
||||||
kwargs = {
|
|
||||||
"get_input": True,
|
|
||||||
"hide_input": True,
|
|
||||||
}
|
|
||||||
while True:
|
|
||||||
pw = print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.PasswordFirstEntry,
|
|
||||||
MsgStyle.PROMPT,
|
|
||||||
params={"journal_name": journal_name},
|
|
||||||
),
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
if not pw:
|
|
||||||
print_msg(Message(MsgText.PasswordCanNotBeEmpty, MsgStyle.WARNING))
|
|
||||||
continue
|
|
||||||
|
|
||||||
elif pw == print_msg(
|
|
||||||
Message(MsgText.PasswordConfirmEntry, MsgStyle.PROMPT), **kwargs
|
|
||||||
):
|
|
||||||
break
|
|
||||||
|
|
||||||
print_msg(Message(MsgText.PasswordDidNotMatch, MsgStyle.ERROR))
|
|
||||||
|
|
||||||
if yesno(Message(MsgText.PasswordStoreInKeychain), default=True):
|
|
||||||
from jrnl.keyring import set_keyring_password
|
|
||||||
|
|
||||||
set_keyring_password(pw, journal_name)
|
|
||||||
|
|
||||||
return pw
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_password(first_try: bool = True) -> str:
|
|
||||||
if not first_try:
|
|
||||||
print_msg(Message(MsgText.WrongPasswordTryAgain, MsgStyle.WARNING))
|
|
||||||
|
|
||||||
return (
|
|
||||||
print_msg(
|
|
||||||
Message(MsgText.Password, MsgStyle.PROMPT),
|
|
||||||
get_input=True,
|
|
||||||
hide_input=True,
|
|
||||||
)
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def yesno(prompt: Message | str, default: bool = True) -> bool:
|
|
||||||
response = print_msgs(
|
|
||||||
[
|
|
||||||
prompt,
|
|
||||||
Message(
|
|
||||||
MsgText.YesOrNoPromptDefaultYes
|
|
||||||
if default
|
|
||||||
else MsgText.YesOrNoPromptDefaultNo
|
|
||||||
),
|
|
||||||
],
|
|
||||||
style=MsgStyle.PROMPT,
|
|
||||||
delimiter=" ",
|
|
||||||
get_input=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
answers = {
|
|
||||||
str(MsgText.OneCharacterYes): True,
|
|
||||||
str(MsgText.OneCharacterNo): False,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Does using `lower()` work in all languages?
|
|
||||||
return answers.get(str(response).lower().strip(), default)
|
|
|
@ -1,18 +0,0 @@
|
||||||
---
|
|
||||||
extension: txt
|
|
||||||
---
|
|
||||||
|
|
||||||
{% block journal %}
|
|
||||||
{% for entry in entries %}
|
|
||||||
{% include entry %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block entry %}
|
|
||||||
{{ entry.title }}
|
|
||||||
{{ "-" * len(entry.title) }}
|
|
||||||
|
|
||||||
{{ entry.body }}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
99
jrnl/time.py
99
jrnl/time.py
|
@ -1,99 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
FAKE_YEAR = 9999
|
|
||||||
DEFAULT_FUTURE = datetime.datetime(FAKE_YEAR, 12, 31, 23, 59, 59)
|
|
||||||
DEFAULT_PAST = datetime.datetime(FAKE_YEAR, 1, 1, 0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def __get_pdt_calendar():
|
|
||||||
try:
|
|
||||||
import parsedatetime.parsedatetime_consts as pdt
|
|
||||||
except ImportError:
|
|
||||||
import parsedatetime as pdt
|
|
||||||
|
|
||||||
consts = pdt.Constants(usePyICU=False)
|
|
||||||
consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
|
|
||||||
calendar = pdt.Calendar(consts)
|
|
||||||
|
|
||||||
return calendar
|
|
||||||
|
|
||||||
|
|
||||||
def parse(
|
|
||||||
date_str: str | datetime.datetime,
|
|
||||||
inclusive: bool = False,
|
|
||||||
default_hour: int | None = None,
|
|
||||||
default_minute: int | None = None,
|
|
||||||
bracketed: bool = False,
|
|
||||||
) -> datetime.datetime | None:
|
|
||||||
"""Parses a string containing a fuzzy date and returns a datetime.datetime object"""
|
|
||||||
if not date_str:
|
|
||||||
return None
|
|
||||||
elif isinstance(date_str, datetime.datetime):
|
|
||||||
return date_str
|
|
||||||
|
|
||||||
# Don't try to parse anything with 6 or fewer characters and was parsed from the existing journal.
|
|
||||||
# It's probably a markdown footnote
|
|
||||||
if len(date_str) <= 6 and bracketed:
|
|
||||||
return None
|
|
||||||
|
|
||||||
default_date = DEFAULT_FUTURE if inclusive else DEFAULT_PAST
|
|
||||||
date = None
|
|
||||||
year_present = False
|
|
||||||
while not date:
|
|
||||||
try:
|
|
||||||
from dateutil.parser import parse as dateparse
|
|
||||||
|
|
||||||
date = dateparse(date_str, default=default_date)
|
|
||||||
if date.year == FAKE_YEAR:
|
|
||||||
date = datetime.datetime(
|
|
||||||
datetime.datetime.now().year, date.timetuple()[1:6]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
year_present = True
|
|
||||||
flag = 1 if date.hour == date.minute == 0 else 2
|
|
||||||
date = date.timetuple()
|
|
||||||
except Exception as e:
|
|
||||||
if e.args[0] == "day is out of range for month":
|
|
||||||
y, m, d, H, M, S = default_date.timetuple()[:6]
|
|
||||||
default_date = datetime.datetime(y, m, d - 1, H, M, S)
|
|
||||||
else:
|
|
||||||
calendar = __get_pdt_calendar()
|
|
||||||
date, flag = calendar.parse(date_str)
|
|
||||||
|
|
||||||
if not flag: # Oops, unparsable.
|
|
||||||
try: # Try and parse this as a single year
|
|
||||||
year = int(date_str)
|
|
||||||
return datetime.datetime(year, 1, 1)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
except TypeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if flag == 1: # Date found, but no time. Use the default time.
|
|
||||||
date = datetime.datetime(
|
|
||||||
*date[:3],
|
|
||||||
hour=23 if inclusive else default_hour or 0,
|
|
||||||
minute=59 if inclusive else default_minute or 0,
|
|
||||||
second=59 if inclusive else 0
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
date = datetime.datetime(*date[:6])
|
|
||||||
|
|
||||||
# Ugly heuristic: if the date is more than 4 weeks in the future, we got the year wrong.
|
|
||||||
# Rather than this, we would like to see parsedatetime patched so we can tell it to prefer
|
|
||||||
# past dates
|
|
||||||
dt = datetime.datetime.now() - date
|
|
||||||
if dt.days < -28 and not year_present:
|
|
||||||
date = date.replace(date.year - 1)
|
|
||||||
return date
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_date(year: int, month: int, day: int) -> bool:
|
|
||||||
try:
|
|
||||||
datetime.datetime(year, month, day)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
212
jrnl/upgrade.py
212
jrnl/upgrade.py
|
@ -1,212 +0,0 @@
|
||||||
# Copyright © 2012-2023 jrnl contributors
|
|
||||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from jrnl import __version__
|
|
||||||
from jrnl.config import is_config_json
|
|
||||||
from jrnl.config import load_config
|
|
||||||
from jrnl.config import scope_config
|
|
||||||
from jrnl.exception import JrnlException
|
|
||||||
from jrnl.journals import Journal
|
|
||||||
from jrnl.journals import open_journal
|
|
||||||
from jrnl.messages import Message
|
|
||||||
from jrnl.messages import MsgStyle
|
|
||||||
from jrnl.messages import MsgText
|
|
||||||
from jrnl.output import print_msg
|
|
||||||
from jrnl.output import print_msgs
|
|
||||||
from jrnl.path import expand_path
|
|
||||||
from jrnl.prompt import yesno
|
|
||||||
|
|
||||||
|
|
||||||
def backup(filename: str, binary: bool = False):
|
|
||||||
filename = expand_path(filename)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(filename, "rb" if binary else "r") as original:
|
|
||||||
contents = original.read()
|
|
||||||
|
|
||||||
with open(filename + ".backup", "wb" if binary else "w") as backup:
|
|
||||||
backup.write(contents)
|
|
||||||
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.BackupCreated, MsgStyle.NORMAL, {"filename": "filename.backup"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
print_msg(Message(MsgText.DoesNotExist, MsgStyle.WARNING, {"name": filename}))
|
|
||||||
cont = yesno(f"\nCreate {filename}?", default=False)
|
|
||||||
if not cont:
|
|
||||||
raise JrnlException(Message(MsgText.UpgradeAborted, MsgStyle.WARNING))
|
|
||||||
|
|
||||||
|
|
||||||
def check_exists(path: str) -> bool:
|
|
||||||
"""
|
|
||||||
Checks if a given path exists.
|
|
||||||
"""
|
|
||||||
return os.path.exists(path)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade_jrnl(config_path: str) -> None:
|
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
print_msg(Message(MsgText.WelcomeToJrnl, MsgStyle.NORMAL, {"version": __version__}))
|
|
||||||
|
|
||||||
encrypted_journals = {}
|
|
||||||
plain_journals = {}
|
|
||||||
other_journals = {}
|
|
||||||
all_journals = []
|
|
||||||
|
|
||||||
for journal_name, journal_conf in config["journals"].items():
|
|
||||||
if isinstance(journal_conf, dict):
|
|
||||||
path = expand_path(journal_conf.get("journal"))
|
|
||||||
encrypt = journal_conf.get("encrypt")
|
|
||||||
else:
|
|
||||||
encrypt = config.get("encrypt")
|
|
||||||
path = expand_path(journal_conf)
|
|
||||||
|
|
||||||
if os.path.exists(path):
|
|
||||||
path = os.path.expanduser(path)
|
|
||||||
else:
|
|
||||||
print_msg(Message(MsgText.DoesNotExist, MsgStyle.ERROR, {"name": path}))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if encrypt:
|
|
||||||
encrypted_journals[journal_name] = path
|
|
||||||
elif os.path.isdir(path):
|
|
||||||
other_journals[journal_name] = path
|
|
||||||
else:
|
|
||||||
plain_journals[journal_name] = path
|
|
||||||
|
|
||||||
kwargs = {
|
|
||||||
# longest journal name
|
|
||||||
"pad": max([len(journal) for journal in config["journals"]]),
|
|
||||||
}
|
|
||||||
|
|
||||||
_print_journal_summary(
|
|
||||||
journals=encrypted_journals,
|
|
||||||
header=Message(
|
|
||||||
MsgText.JournalsToUpgrade,
|
|
||||||
params={
|
|
||||||
"version": __version__,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
_print_journal_summary(
|
|
||||||
journals=plain_journals,
|
|
||||||
header=Message(
|
|
||||||
MsgText.JournalsToUpgrade,
|
|
||||||
params={
|
|
||||||
"version": __version__,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
_print_journal_summary(
|
|
||||||
journals=other_journals,
|
|
||||||
header=Message(MsgText.JournalsToIgnore),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
cont = yesno(Message(MsgText.ContinueUpgrade), default=False)
|
|
||||||
if not cont:
|
|
||||||
raise JrnlException(Message(MsgText.UpgradeAborted, MsgStyle.WARNING))
|
|
||||||
|
|
||||||
for journal_name, path in encrypted_journals.items():
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.UpgradingJournal,
|
|
||||||
params={
|
|
||||||
"journal_name": journal_name,
|
|
||||||
"path": path,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
backup(path, binary=True)
|
|
||||||
old_journal = open_journal(
|
|
||||||
journal_name, scope_config(config, journal_name), legacy=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.debug(f"Clearing encryption method for '{journal_name}' journal")
|
|
||||||
|
|
||||||
# Update the encryption method
|
|
||||||
new_journal = Journal.from_journal(old_journal)
|
|
||||||
new_journal.config["encrypt"] = "jrnlv2"
|
|
||||||
new_journal._get_encryption_method()
|
|
||||||
# Copy over password (jrnlv1 only supported password-based encryption)
|
|
||||||
new_journal.encryption_method.password = old_journal.encryption_method.password
|
|
||||||
|
|
||||||
all_journals.append(new_journal)
|
|
||||||
|
|
||||||
for journal_name, path in plain_journals.items():
|
|
||||||
print_msg(
|
|
||||||
Message(
|
|
||||||
MsgText.UpgradingJournal,
|
|
||||||
params={
|
|
||||||
"journal_name": journal_name,
|
|
||||||
"path": path,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
backup(path)
|
|
||||||
old_journal = open_journal(
|
|
||||||
journal_name, scope_config(config, journal_name), legacy=True
|
|
||||||
)
|
|
||||||
all_journals.append(Journal.from_journal(old_journal))
|
|
||||||
|
|
||||||
# loop through lists to validate
|
|
||||||
failed_journals = [j for j in all_journals if not j.validate_parsing()]
|
|
||||||
|
|
||||||
if len(failed_journals) > 0:
|
|
||||||
raise JrnlException(
|
|
||||||
Message(MsgText.AbortingUpgrade, MsgStyle.WARNING),
|
|
||||||
Message(
|
|
||||||
MsgText.JournalFailedUpgrade,
|
|
||||||
MsgStyle.ERROR,
|
|
||||||
{
|
|
||||||
"s": "s" if len(failed_journals) > 1 else "",
|
|
||||||
"failed_journals": "\n".join(j.name for j in failed_journals),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# write all journals - or - don't
|
|
||||||
for j in all_journals:
|
|
||||||
j.write()
|
|
||||||
|
|
||||||
print_msg(Message(MsgText.UpgradingConfig, MsgStyle.NORMAL))
|
|
||||||
|
|
||||||
backup(config_path)
|
|
||||||
|
|
||||||
print_msg(Message(MsgText.AllDoneUpgrade, MsgStyle.NORMAL))
|
|
||||||
|
|
||||||
|
|
||||||
def is_old_version(config_path: str) -> bool:
|
|
||||||
return is_config_json(config_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_journal_summary(journals: dict, header: Message, pad: int) -> None:
|
|
||||||
if not journals:
|
|
||||||
return
|
|
||||||
|
|
||||||
msgs = [header]
|
|
||||||
for journal, path in journals.items():
|
|
||||||
msgs.append(
|
|
||||||
Message(
|
|
||||||
MsgText.PaddedJournalName,
|
|
||||||
params={
|
|
||||||
"journal_name": journal,
|
|
||||||
"path": path,
|
|
||||||
"pad": pad,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print_msgs(msgs)
|
|
Loading…
Add table
Reference in a new issue