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
![]() |
|||
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
|
|||
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 |