clean up more files

This commit is contained in:
Jonathan Wren 2023-05-06 16:05:02 -07:00
parent 0794b5ca4b
commit 3be496e8c3
No known key found for this signature in database
47 changed files with 0 additions and 5114 deletions

View file

@ -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"

View file

@ -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())

View file

@ -1 +0,0 @@
__version__ = "v4.0-beta3"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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),
},
),
)

View file

@ -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())

View file

@ -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,
},
)
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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])

View file

@ -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]

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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 = {}

View file

@ -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)

View file

@ -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.
"""

View file

@ -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

View file

@ -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())

View file

@ -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)

View file

@ -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]

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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,
},
)
)

View file

@ -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,
},
)
)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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]

View file

@ -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()

View file

@ -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))

View file

@ -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)

View file

@ -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 %}

View file

@ -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

View file

@ -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)