jrnl.DayOneJournal.DayOne.editable_str()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
#!/usr/bin/env python
2
3
from datetime import datetime
4
import fnmatch
5
import os
6
from pathlib import Path
7
import platform
8
import plistlib
9
import re
10
import socket
11
import time
12
import uuid
13
from xml.parsers.expat import ExpatError
14
15
import pytz
16
import tzlocal
17
18
from . import Entry
19
from . import Journal
20
from . import __title__
21
from . import __version__
22
23
24
class DayOne(Journal.Journal):
25
    """A special Journal handling DayOne files"""
26
27
    # InvalidFileException was added to plistlib in Python3.4
28
    PLIST_EXCEPTIONS = (
29
        (ExpatError, plistlib.InvalidFileException)
30
        if hasattr(plistlib, "InvalidFileException")
31
        else ExpatError
32
    )
33
34
    def __init__(self, **kwargs):
35
        self.entries = []
36
        self._deleted_entries = []
37
        super().__init__(**kwargs)
38
39
    def open(self):
40
        filenames = [
41
            os.path.join(self.config["journal"], "entries", f)
42
            for f in os.listdir(os.path.join(self.config["journal"], "entries"))
43
        ]
44
        filenames = []
45
        for root, dirnames, f in os.walk(self.config["journal"]):
46
            for filename in fnmatch.filter(f, "*.doentry"):
47
                filenames.append(os.path.join(root, filename))
48
        self.entries = []
49
        for filename in filenames:
50
            with open(filename, "rb") as plist_entry:
51
                try:
52
                    dict_entry = plistlib.load(plist_entry, fmt=plistlib.FMT_XML)
53
                except self.PLIST_EXCEPTIONS:
54
                    pass
55
                else:
56
                    try:
57
                        timezone = pytz.timezone(dict_entry["Time Zone"])
58
                    except (KeyError, pytz.exceptions.UnknownTimeZoneError):
59
                        timezone = tzlocal.get_localzone()
60
                    date = dict_entry["Creation Date"]
61
                    # convert the date to UTC rather than keep messing with
62
                    # timezones
63
                    if timezone.zone != "UTC":
64
                        date = date + timezone.utcoffset(date, is_dst=False)
65
66
                    entry = Entry.Entry(
67
                        self,
68
                        date,
69
                        text=dict_entry["Entry Text"],
70
                        starred=dict_entry["Starred"],
71
                    )
72
                    entry.uuid = dict_entry["UUID"]
73
                    entry._tags = [
74
                        self.config["tagsymbols"][0] + tag.lower()
75
                        for tag in dict_entry.get("Tags", [])
76
                    ]
77
78
                    """Extended DayOne attributes"""
79
                    try:
80
                        entry.creator_device_agent = dict_entry["Creator"][
81
                            "Device Agent"
82
                        ]
83
                    except:
84
                        pass
85
                    try:
86
                        entry.creator_generation_date = dict_entry["Creator"][
87
                            "Generation Date"
88
                        ]
89
                    except:
90
                        entry.creator_generation_date = date
91
                    try:
92
                        entry.creator_host_name = dict_entry["Creator"]["Host Name"]
93
                    except:
94
                        pass
95
                    try:
96
                        entry.creator_os_agent = dict_entry["Creator"]["OS Agent"]
97
                    except:
98
                        pass
99
                    try:
100
                        entry.creator_software_agent = dict_entry["Creator"][
101
                            "Software Agent"
102
                        ]
103
                    except:
104
                        pass
105
                    try:
106
                        entry.location = dict_entry["Location"]
107
                    except:
108
                        pass
109
                    try:
110
                        entry.weather = dict_entry["Weather"]
111
                    except:
112
                        pass
113
                    self.entries.append(entry)
114
        self.sort()
115
        return self
116
117
    def write(self):
118
        """Writes only the entries that have been modified into plist files."""
119
        for entry in self.entries:
120
            if entry.modified:
121
                utc_time = datetime.utcfromtimestamp(
122
                    time.mktime(entry.date.timetuple())
123
                )
124
125
                if not hasattr(entry, "uuid"):
126
                    entry.uuid = uuid.uuid1().hex
127
                if not hasattr(entry, "creator_device_agent"):
128
                    entry.creator_device_agent = ""  # iPhone/iPhone5,3
129
                if not hasattr(entry, "creator_generation_date"):
130
                    entry.creator_generation_date = utc_time
131
                if not hasattr(entry, "creator_host_name"):
132
                    entry.creator_host_name = socket.gethostname()
133
                if not hasattr(entry, "creator_os_agent"):
134
                    entry.creator_os_agent = "{}/{}".format(
135
                        platform.system(), platform.release()
136
                    )
137
                if not hasattr(entry, "creator_software_agent"):
138
                    entry.creator_software_agent = "{}/{}".format(
139
                        __title__, __version__
140
                    )
141
142
                fn = (
143
                    Path(self.config["journal"])
144
                    / "entries"
145
                    / (entry.uuid.upper() + ".doentry")
146
                )
147
148
                entry_plist = {
149
                    "Creation Date": utc_time,
150
                    "Starred": entry.starred if hasattr(entry, "starred") else False,
151
                    "Entry Text": entry.title + "\n" + entry.body,
152
                    "Time Zone": str(tzlocal.get_localzone()),
153
                    "UUID": entry.uuid.upper(),
154
                    "Tags": [
155
                        tag.strip(self.config["tagsymbols"]).replace("_", " ")
156
                        for tag in entry.tags
157
                    ],
158
                    "Creator": {
159
                        "Device Agent": entry.creator_device_agent,
160
                        "Generation Date": entry.creator_generation_date,
161
                        "Host Name": entry.creator_host_name,
162
                        "OS Agent": entry.creator_os_agent,
163
                        "Software Agent": entry.creator_software_agent,
164
                    },
165
                }
166
                if hasattr(entry, "location"):
167
                    entry_plist["Location"] = entry.location
168
                if hasattr(entry, "weather"):
169
                    entry_plist["Weather"] = entry.weather
170
171
                # plistlib expects a binary object
172
                with fn.open(mode="wb") as f:
173
                    plistlib.dump(entry_plist, f, fmt=plistlib.FMT_XML, sort_keys=False)
174
175
        for entry in self._deleted_entries:
176
            filename = os.path.join(
177
                self.config["journal"], "entries", entry.uuid + ".doentry"
178
            )
179
            os.remove(filename)
180
181
    def editable_str(self):
182
        """Turns the journal into a string of entries that can be edited
183
        manually and later be parsed with eslf.parse_editable_str."""
184
        return "\n".join([f"{str(e)}\n# {e.uuid}\n" for e in self.entries])
185
186
    def _update_old_entry(self, entry, new_entry):
187
        for attr in ("title", "body", "date"):
188
            old_attr = getattr(entry, attr)
189
            new_attr = getattr(new_entry, attr)
190
            if old_attr != new_attr:
191
                entry.modified = True
192
                setattr(entry, attr, new_attr)
193
194
    def _get_and_remove_uuid_from_entry(self, entry):
195
        uuid_regex = "^ *?# ([a-zA-Z0-9]+) *?$"
196
        m = re.search(uuid_regex, entry.body, re.MULTILINE)
197
        entry.uuid = m.group(1) if m else None
198
199
        # remove the uuid from the body
200
        entry.body = re.sub(uuid_regex, "", entry.body, flags=re.MULTILINE, count=1)
201
        entry.body = entry.body.rstrip()
202
203
        return entry
204
205
    def parse_editable_str(self, edited):
206
        """Parses the output of self.editable_str and updates its entries."""
207
        # Method: create a new list of entries from the edited text, then match
208
        # UUIDs of the new entries against self.entries, updating the entries
209
        # if the edited entries differ, and deleting entries from self.entries
210
        # if they don't show up in the edited entries anymore.
211
        entries_from_editor = self._parse(edited)
212
213
        for entry in entries_from_editor:
214
            entry = self._get_and_remove_uuid_from_entry(entry)
215
216
        # Remove deleted entries
217
        edited_uuids = [e.uuid for e in entries_from_editor]
218
        self._deleted_entries = [e for e in self.entries if e.uuid not in edited_uuids]
219
        self.entries[:] = [e for e in self.entries if e.uuid in edited_uuids]
220
221
        for entry in entries_from_editor:
222
            for old_entry in self.entries:
223
                if entry.uuid == old_entry.uuid:
224
                    self._update_old_entry(old_entry, entry)
225
                    break
226