mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 08:38:32 +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