jrnl.Journal   F
last analyzed

Complexity

Total Complexity 78

Size/Duplication

Total Lines 418
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 265
dl 0
loc 418
rs 2.16
c 0
b 0
f 0
wmc 78

32 Methods

Rating   Name   Duplication   Size   Complexity  
A Journal.__len__() 0 3 1
A Journal.from_journal() 0 13 1
A Tag.__repr__() 0 2 1
A Journal.__iter__() 0 3 1
A Journal.import_() 0 5 1
A Tag.__str__() 0 2 1
A Tag.__init__() 0 3 1
A Journal.__init__() 0 17 1
A LegacyJournal._load() 0 3 2
A Journal.filter() 0 52 4
B Journal.new_entry() 0 35 7
A Journal._load() 0 2 1
A Journal.__str__() 0 2 1
A Journal.parse_editable_str() 0 9 2
A Journal.create_file() 0 4 2
A Journal.write() 0 5 1
A Journal.prompt_delete_entries() 0 17 3
A Journal.open() 0 17 3
A Journal.tags() 0 9 1
A Journal.__repr__() 0 2 1
A Journal.validate_parsing() 0 7 3
A Journal.editable_str() 0 4 1
A Journal.sort() 0 3 2
A PlainJournal._load() 0 3 2
C LegacyJournal._parse() 0 45 9
A Journal._store() 0 3 1
B Journal._parse() 0 36 8
A PlainJournal._store() 0 3 2
A Journal._to_text() 0 2 1
A Journal.delete_entries() 0 4 2
A Journal.pprint() 0 3 1
A Journal.limit() 0 4 2

1 Function

Rating   Name   Duplication   Size   Complexity  
B open_journal() 0 38 8

How to fix   Complexity   

Complexity

Complex classes like jrnl.Journal often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
#!/usr/bin/env python
2
3
from datetime import datetime
4
import logging
5
import os
6
import re
7
import sys
8
9
from . import Entry
10
from . import time
11
from .prompt import yesno
12
13
14
class Tag:
15
    def __init__(self, name, count=0):
16
        self.name = name
17
        self.count = count
18
19
    def __str__(self):
20
        return self.name
21
22
    def __repr__(self):
23
        return f"<Tag '{self.name}'>"
24
25
26
class Journal:
27
    def __init__(self, name="default", **kwargs):
28
        self.config = {
29
            "journal": "journal.txt",
30
            "encrypt": False,
31
            "default_hour": 9,
32
            "default_minute": 0,
33
            "timeformat": "%Y-%m-%d %H:%M",
34
            "tagsymbols": "@",
35
            "highlight": True,
36
            "linewrap": 80,
37
            "indent_character": "|",
38
        }
39
        self.config.update(kwargs)
40
        # Set up date parser
41
        self.search_tags = None  # Store tags we're highlighting
42
        self.name = name
43
        self.entries = []
44
45
    def __len__(self):
46
        """Returns the number of entries"""
47
        return len(self.entries)
48
49
    def __iter__(self):
50
        """Iterates over the journal's entries."""
51
        return (entry for entry in self.entries)
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable entry does not seem to be defined.
Loading history...
52
53
    @classmethod
54
    def from_journal(cls, other):
55
        """Creates a new journal by copying configuration and entries from
56
        another journal object"""
57
        new_journal = cls(other.name, **other.config)
58
        new_journal.entries = other.entries
59
        logging.debug(
60
            "Imported %d entries from %s to %s",
61
            len(new_journal),
62
            other.__class__.__name__,
63
            cls.__name__,
64
        )
65
        return new_journal
66
67
    def import_(self, other_journal_txt):
68
        self.entries = list(
69
            frozenset(self.entries) | frozenset(self._parse(other_journal_txt))
70
        )
71
        self.sort()
72
73
    def open(self, filename=None):
74
        """Opens the journal file defined in the config and parses it into a list of Entries.
75
        Entries have the form (date, title, body)."""
76
        filename = filename or self.config["journal"]
77
        dirname = os.path.dirname(filename)
78
        if not os.path.exists(filename):
79
            if not os.path.isdir(dirname):
80
                os.makedirs(dirname)
81
                print(f"[Directory {dirname} created]", file=sys.stderr)
82
            self.create_file(filename)
83
            print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr)
84
85
        text = self._load(filename)
86
        self.entries = self._parse(text)
87
        self.sort()
88
        logging.debug("opened %s with %d entries", self.__class__.__name__, len(self))
89
        return self
90
91
    def write(self, filename=None):
92
        """Dumps the journal into the config file, overwriting it"""
93
        filename = filename or self.config["journal"]
94
        text = self._to_text()
95
        self._store(filename, text)
96
97
    def validate_parsing(self):
98
        """Confirms that the jrnl is still parsed correctly after being dumped to text."""
99
        new_entries = self._parse(self._to_text())
100
        for i, entry in enumerate(self.entries):
101
            if entry != new_entries[i]:
102
                return False
103
        return True
104
105
    @staticmethod
106
    def create_file(filename):
107
        with open(filename, "w"):
108
            pass
109
110
    def _to_text(self):
111
        return "\n".join([str(e) for e in self.entries])
112
113
    def _load(self, filename):
114
        raise NotImplementedError
115
116
    @classmethod
117
    def _store(filename, text):
118
        raise NotImplementedError
119
120
    def _parse(self, journal_txt):
121
        """Parses a journal that's stored in a string and returns a list of entries"""
122
123
        # Return empty array if the journal is blank
124
        if not journal_txt:
125
            return []
126
127
        # Initialise our current entry
128
        entries = []
129
130
        date_blob_re = re.compile("(?:^|\n)\\[([^\\]]+)\\] ")
131
        last_entry_pos = 0
132
        for match in date_blob_re.finditer(journal_txt):
133
            date_blob = match.groups()[0]
134
            try:
135
                new_date = datetime.strptime(date_blob, self.config["timeformat"])
136
            except ValueError:
137
                # Passing in a date that had brackets around it
138
                new_date = time.parse(date_blob, bracketed=True)
139
140
            if new_date:
141
                if entries:
142
                    entries[-1].text = journal_txt[last_entry_pos : match.start()]
143
                last_entry_pos = match.end()
144
                entries.append(Entry.Entry(self, date=new_date))
145
146
        # If no entries were found, treat all the existing text as an entry made now
147
        if not entries:
148
            entries.append(Entry.Entry(self, date=time.parse("now")))
149
150
        # Fill in the text of the last entry
151
        entries[-1].text = journal_txt[last_entry_pos:]
152
153
        for entry in entries:
154
            entry._parse_text()
155
        return entries
156
157
    def pprint(self, short=False):
158
        """Prettyprints the journal's entries"""
159
        return "\n".join([e.pprint(short=short) for e in self.entries])
160
161
    def __str__(self):
162
        return self.pprint()
163
164
    def __repr__(self):
165
        return f"<Journal with {len(self.entries)} entries>"
166
167
    def sort(self):
168
        """Sorts the Journal's entries by date"""
169
        self.entries = sorted(self.entries, key=lambda entry: entry.date)
170
171
    def limit(self, n=None):
172
        """Removes all but the last n entries"""
173
        if n:
174
            self.entries = self.entries[-n:]
175
176
    @property
177
    def tags(self):
178
        """Returns a set of tuples (count, tag) for all tags present in the journal."""
179
        # Astute reader: should the following line leave you as puzzled as me the first time
180
        # I came across this construction, worry not and embrace the ensuing moment of enlightment.
181
        tags = [tag for entry in self.entries for tag in set(entry.tags)]
182
        # To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
183
        tag_counts = {(tags.count(tag), tag) for tag in tags}
184
        return [Tag(tag, count=count) for count, tag in sorted(tag_counts)]
185
186
    def filter(
187
        self,
188
        tags=[],
189
        start_date=None,
190
        end_date=None,
191
        starred=False,
192
        strict=False,
193
        contains=None,
194
        exclude=[],
195
    ):
196
        """Removes all entries from the journal that don't match the filter.
197
198
        tags is a list of tags, each being a string that starts with one of the
199
        tag symbols defined in the config, e.g. ["@John", "#WorldDomination"].
200
201
        start_date and end_date define a timespan by which to filter.
202
203
        starred limits journal to starred entries
204
205
        If strict is True, all tags must be present in an entry. If false, the
206
207
        exclude is a list of the tags which should not appear in the results.
208
        entry is kept if any tag is present, unless they appear in exclude."""
209
        self.search_tags = {tag.lower() for tag in tags}
210
        excluded_tags = {tag.lower() for tag in exclude}
211
        end_date = time.parse(end_date, inclusive=True)
212
        start_date = time.parse(start_date)
213
214
        # If strict mode is on, all tags have to be present in entry
215
        tagged = self.search_tags.issubset if strict else self.search_tags.intersection
216
        excluded = lambda tags: len([tag for tag in tags if tag in excluded_tags]) > 0
217
        if contains:
218
            contains_lower = contains.casefold()
219
220
        result = [
221
            entry
222
            for entry in self.entries
223
            if (not tags or tagged(entry.tags))
224
            and (not starred or entry.starred)
225
            and (not start_date or entry.date >= start_date)
226
            and (not end_date or entry.date <= end_date)
227
            and (not exclude or not excluded(entry.tags))
228
            and (
229
                not contains
230
                or (
231
                    contains_lower in entry.title.casefold()
0 ignored issues
show
introduced by
The variable contains_lower does not seem to be defined in case contains on line 217 is False. Are you sure this can never be the case?
Loading history...
232
                    or contains_lower in entry.body.casefold()
233
                )
234
            )
235
        ]
236
237
        self.entries = result
238
239
    def delete_entries(self, entries_to_delete):
240
        """Deletes specific entries from a journal."""
241
        for entry in entries_to_delete:
242
            self.entries.remove(entry)
243
244
    def prompt_delete_entries(self):
245
        """Prompts for deletion of each of the entries in a journal.
246
        Returns the entries the user wishes to delete."""
247
248
        to_delete = []
249
250
        def ask_delete(entry):
251
            return yesno(
252
                f"Delete entry '{entry.pprint(short=True)}'?",
253
                default=False,
254
            )
255
256
        for entry in self.entries:
257
            if ask_delete(entry):
258
                to_delete.append(entry)
259
260
        return to_delete
261
262
    def new_entry(self, raw, date=None, sort=True):
263
        """Constructs a new entry from some raw text input.
264
        If a date is given, it will parse and use this, otherwise scan for a date in the input first."""
265
266
        raw = raw.replace("\\n ", "\n").replace("\\n", "\n")
267
        # Split raw text into title and body
268
        sep = re.search(r"\n|[?!.]+ +\n?", raw)
269
        first_line = raw[: sep.end()].strip() if sep else raw
270
        starred = False
271
272
        if not date:
273
            colon_pos = first_line.find(": ")
274
            if colon_pos > 0:
275
                date = time.parse(
276
                    raw[:colon_pos],
277
                    default_hour=self.config["default_hour"],
278
                    default_minute=self.config["default_minute"],
279
                )
280
                if date:  # Parsed successfully, strip that from the raw text
281
                    starred = raw[:colon_pos].strip().endswith("*")
282
                    raw = raw[colon_pos + 1 :].strip()
283
        starred = (
284
            starred
285
            or first_line.startswith("*")
286
            or first_line.endswith("*")
287
            or raw.startswith("*")
288
        )
289
        if not date:  # Still nothing? Meh, just live in the moment.
290
            date = time.parse("now")
291
        entry = Entry.Entry(self, date, raw, starred=starred)
292
        entry.modified = True
293
        self.entries.append(entry)
294
        if sort:
295
            self.sort()
296
        return entry
297
298
    def editable_str(self):
299
        """Turns the journal into a string of entries that can be edited
300
        manually and later be parsed with eslf.parse_editable_str."""
301
        return "\n".join([str(e) for e in self.entries])
302
303
    def parse_editable_str(self, edited):
304
        """Parses the output of self.editable_str and updates it's entries."""
305
        mod_entries = self._parse(edited)
306
        # Match those entries that can be found in self.entries and set
307
        # these to modified, so we can get a count of how many entries got
308
        # modified and how many got deleted later.
309
        for entry in mod_entries:
310
            entry.modified = not any(entry == old_entry for old_entry in self.entries)
311
        self.entries = mod_entries
312
313
314
class PlainJournal(Journal):
315
    def _load(self, filename):
316
        with open(filename, "r", encoding="utf-8") as f:
317
            return f.read()
318
319
    def _store(self, filename, text):
320
        with open(filename, "w", encoding="utf-8") as f:
321
            f.write(text)
322
323
324
class LegacyJournal(Journal):
325
    """Legacy class to support opening journals formatted with the jrnl 1.x
326
    standard. Main difference here is that in 1.x, timestamps were not cuddled
327
    by square brackets. You'll not be able to save these journals anymore."""
328
329
    def _load(self, filename):
330
        with open(filename, "r", encoding="utf-8") as f:
331
            return f.read()
332
333
    def _parse(self, journal_txt):
334
        """Parses a journal that's stored in a string and returns a list of entries"""
335
        # Entries start with a line that looks like 'date title' - let's figure out how
336
        # long the date will be by constructing one
337
        date_length = len(datetime.today().strftime(self.config["timeformat"]))
338
339
        # Initialise our current entry
340
        entries = []
341
        current_entry = None
342
        new_date_format_regex = re.compile(r"(^\[[^\]]+\].*?$)")
343
        for line in journal_txt.splitlines():
344
            line = line.rstrip()
345
            try:
346
                # try to parse line as date => new entry begins
347
                new_date = datetime.strptime(
348
                    line[:date_length], self.config["timeformat"]
349
                )
350
351
                # parsing successful => save old entry and create new one
352
                if new_date and current_entry:
353
                    entries.append(current_entry)
354
355
                if line.endswith("*"):
356
                    starred = True
357
                    line = line[:-1]
358
                else:
359
                    starred = False
360
361
                current_entry = Entry.Entry(
362
                    self, date=new_date, text=line[date_length + 1 :], starred=starred
363
                )
364
            except ValueError:
365
                # Happens when we can't parse the start of the line as an date.
366
                # In this case, just append line to our body (after some
367
                # escaping for the new format).
368
                line = new_date_format_regex.sub(r" \1", line)
369
                if current_entry:
370
                    current_entry.text += line + "\n"
371
372
        # Append last entry
373
        if current_entry:
374
            entries.append(current_entry)
375
        for entry in entries:
376
            entry._parse_text()
377
        return entries
378
379
380
def open_journal(journal_name, config, legacy=False):
381
    """
382
    Creates a normal, encrypted or DayOne journal based on the passed config.
383
    If legacy is True, it will open Journals with legacy classes build for
384
    backwards compatibility with jrnl 1.x
385
    """
386
    config = config.copy()
387
    config["journal"] = os.path.expanduser(os.path.expandvars(config["journal"]))
388
389
    if os.path.isdir(config["journal"]):
390
        if config["journal"].strip("/").endswith(".dayone") or "entries" in os.listdir(
391
            config["journal"]
392
        ):
393
            from . import DayOneJournal
394
395
            return DayOneJournal.DayOne(**config).open()
396
        else:
397
            from . import FolderJournal
398
399
            return FolderJournal.Folder(**config).open()
400
401
    if not config["encrypt"]:
402
        if legacy:
403
            return LegacyJournal(journal_name, **config).open()
404
        return PlainJournal(journal_name, **config).open()
405
406
    from . import EncryptedJournal
407
408
    try:
409
        if legacy:
410
            return EncryptedJournal.LegacyEncryptedJournal(
411
                journal_name, **config
412
            ).open()
413
        return EncryptedJournal.EncryptedJournal(journal_name, **config).open()
414
    except KeyboardInterrupt:
415
        # Since encrypted journals prompt for a password, it's easy for a user to ctrl+c out
416
        print("[Interrupted while opening journal]", file=sys.stderr)
417
        sys.exit(1)
418