Example #1
0
    def __init__(self, configpath=''):
        # load existing configuration files if exists:
        if not configpath:
            configpath = os.path.join(os.path.expanduser('~'), '.poiconfig.json')
        if not os.path.exists(configpath):
            print('poi: configuration file does not exist. Please run poi-config first.')
            sys.exit(0)

        with open(configpath) as f:
            config = json.load(f)
        for key, value in config.items():
            setattr(self, key, value)

        self.color_style = style_from_dict({
            Token.Title: self.color['title'],
            Token.Link: self.color['link'],
            Token.HL1: self.color['highlight'][0],
            Token.HL2: self.color['highlight'][1],
            Token.HL3: self.color['highlight'][2],
            Token.Tag: self.color['tag'],
            Token.Text: '#ffffff roman',
            })

        self.notes = os.path.join(self.root, 'notes')
        self.refs = os.path.join(self.root, 'refs')
        self.backups = os.path.join(self.root, '.backups')

        self.history = []
        self.link_listing = []
        self.last_notepath = ''

        self.index = PoiIndex(os.path.join(self.root, '.index.txt'), os.path.join(self.notes, '*' + self.file_ext))
        self.tag_index = TagIndex(os.path.join(self.root, '.tag_index.txt'), os.path.join(self.notes, '*' + self.file_ext))
Example #2
0
class NoteManager(object):
    """
    Backend for poi.

    Takes care of creating a new file for a note, updating the filename when
    editing and displaying the note; searching and listing notes.

    """

    def __init__(self, configpath=''):
        # load existing configuration files if exists:
        if not configpath:
            configpath = os.path.join(os.path.expanduser('~'), '.poiconfig.json')
        if not os.path.exists(configpath):
            print('poi: configuration file does not exist. Please run poi-config first.')
            sys.exit(0)

        with open(configpath) as f:
            config = json.load(f)
        for key, value in config.items():
            setattr(self, key, value)

        self.color_style = style_from_dict({
            Token.Title: self.color['title'],
            Token.Link: self.color['link'],
            Token.HL1: self.color['highlight'][0],
            Token.HL2: self.color['highlight'][1],
            Token.HL3: self.color['highlight'][2],
            Token.Tag: self.color['tag'],
            Token.Text: '#ffffff roman',
            })

        self.notes = os.path.join(self.root, 'notes')
        self.refs = os.path.join(self.root, 'refs')
        self.backups = os.path.join(self.root, '.backups')

        self.history = []
        self.link_listing = []
        self.last_notepath = ''

        self.index = PoiIndex(os.path.join(self.root, '.index.txt'), os.path.join(self.notes, '*' + self.file_ext))
        self.tag_index = TagIndex(os.path.join(self.root, '.tag_index.txt'), os.path.join(self.notes, '*' + self.file_ext))

    def identifier_to_notepath(self, note_id):
        pattern = os.path.join(self.notes, note_id + '*' + self.file_ext)
        notepaths = glob.glob(pattern)  # Will be empty or singleton
        if not notepaths:
            return None
        else:
            return notepaths[0]

    def open_link(self, link):
        # First check whether link is another note:
        m = re.match(r'(\d\d\d\d)-(\d\d)-(\d\d)-(\d+)', link)
        if m:
            # Y, m, d, num = m.groups()
            pattern = os.path.join(self.notes, link + '*' + self.file_ext)
            notepaths = glob.glob(pattern)  # Will be empty or singleton
            if notepaths:
                self.display_note(notepaths[0])
                self.last_notepath = notepaths[0]
                return None
            else:
                print('poi: invalid note identifier')
                return None

        # Otherwise check whether it is a reference:
        refpath = os.path.join(self.refs, link)
        if os.path.exists(refpath):
            return_code = subprocess.call('/usr/bin/open ' + ' ' + '"' + refpath + '"', shell=True)
            return None

        # If nothing else matches, assume link is a url
        return_code = subprocess.call('/usr/bin/open ' + ' ' + '"' + link + '"', shell=True)
        return None

    def generate_notepath(self, date=None):
        """Generate a unique filepath for a note.

        If date is given, use is as a basis; if not use today as the date.

        Parameter
        ---------
            date : str of form 'yyyymmdd', optional
        """

        # If date is not given, use today's date:
        if date is None:
            date = datetime.date.today().strftime('%Y-%m-%d')
        else:
            date = date.strftime('%Y-%m-%d')

        # Modification date, always today:
        m_date = datetime.date.today().strftime('%Y-%m-%d')

        # Loop until a unique filepath is found:
        count = 1
        while True:
            # filename is of form:
            # YYYY-MM-DD-<num>-YYYY-MM-DD<file_ext>
            filename = date + '-' + str(count) + '-' + m_date + self.file_ext
            filepath = os.path.join(self.root, 'notes', filename)
            if os.path.exists(filepath):
                count += 1
            else:
                break
        return filepath

    def list_notes(self):
        """
        Return a list of all filepaths of notes.
        """
        pattern = os.path.join(self.root, 'notes', '*' + self.file_ext)
        return glob.glob(pattern)

    def list_dates(self):
        creation_dates, modification_dates = set(), set()
        for notepath in self.list_notes():
            c_date, m_date, _, _ = extract_filename_info(notepath)
            creation_dates.add(c_date.strftime('%Y-%m-%d %a'))
            modification_dates.add(m_date.strftime('%Y-%m-%d %a'))
        return sorted(creation_dates), sorted(modification_dates)

    def count_tags(self):
        """
        Collect all tags from notes and return as a sorted list.

        TODO: keep a list of tags in a file with a date for its creation.
        Whenever this function is called, only run through those notes whose
        modification date is mote recent that the tags lists date. This can
        save a 1-2 seconds from the user having to wait for the tag list to be
        generated.
        """
        tags = Counter()
        for tag, paths in self.tag_index.index.items():
            tags[tag] = len(paths)
        return tags

    def filter_notes_by_date(self, notes, criteria):
        """
        Filter out notes whose date(s) do not meet criteria.

        Parameters
        ----------
        notes : list
            A list of filepaths of notes.
        criteria : dics
        A dictionary possibly containing several filtering criteria. In this
        function, the follwoing criteria are used, each of which is a date
        object:
            - min_creation_date
            - max_creation_date
            - min_modification_date
            - max_modification_date

        Returns
        -------
        A list of notes whose date(s) meet the criteria.
        """
        result = []

        if 'min_creation_date' not in criteria:
            criteria['min_creation_date'] = datetime.date(1970, 1, 1)
        if 'min_modification_date' not in criteria:
            criteria['min_modification_date'] = datetime.date(1970, 1, 1)
        if 'max_creation_date' not in criteria:
            criteria['max_creation_date'] = datetime.date(9999, 12, 31)
        if 'max_modification_date' not in criteria:
            criteria['max_modification_date'] = datetime.date(9999, 12, 31)

        for notepath in notes:
            creation_date, modification_date, _, _ = extract_filename_info(notepath)
            if creation_date < criteria['min_creation_date']:
                continue
            if creation_date > criteria['max_creation_date']:
                continue
            if modification_date < criteria['min_modification_date']:
                continue
            if modification_date > criteria['max_modification_date']:
                continue
            result.append(notepath)
        return result

    def add_note(self, date=None, tags=[]):
        """
        Create a unique filepath for a new note and open it in an editor.

        Parameters
        ----------
        date : datetime.date, optional
            Creation date of the note.
        tags : list, optional
            A list of tags of the new note.

        Returns
        -------
        int
            Return code given by trying to open with ``subprocess.call``.
        """
        notepath = self.generate_notepath(date)
        if tags:
            with open(notepath, 'wt') as f:
                f.write(10 * '\n' + '#: ' + ', '.join(tags))
        pathlib.Path(notepath).touch()
        return_code = subprocess.call(self.editor_cmd + ' ' + notepath, shell=True)
        # The next line attached the newly created note to _
        self.last_notepath = notepath
        return return_code

    def update_notepath(self, old_notepath, modification_date, creation_date=None):
        """Update a note's filename when it is edited.

        Also history get rewritten with the new filename.
        """
        # old_notepath is form
        # <poiroot>/notes/YYYY-MM-DD-<num>-YYYY-MM-DD<file_ext>

        # dirname is of form
        # <poiroot>/notes
        dirname = os.path.dirname(old_notepath)

        # basename is of form
        # YYYY-MM-DD-<num>-YYYY-MM-DD<file_ext>
        basename = os.path.basename(old_notepath)

        # root = YYYY-MM-DD-<num>-YYYY-MM-DD
        # ext = <file_ext>
        root, ext = os.path.splitext(basename)

        # Extract creation and modification dates as datetime objects and
        # number as an int:
        c_date, m_date, num, _ = extract_filename_info(old_notepath)

        if creation_date:
            c_date = creation_date
        else:
            c_date = c_date.strftime('%Y-%m-%d')

        # Replace previous modification date by the new one, given as an argument:
        m_date = modification_date

        new_notepath = os.path.join(dirname, c_date + '-' + str(num) + '-' + m_date + ext)

        # Create a backup copy of the old file:
        t = str(int(time.time()))
        backup_path = os.path.join(self.backups, root + '-' + t + ext)
        shutil.copy(old_notepath, backup_path)

        # Update the filename with the new modification date:
        shutil.move(old_notepath, new_notepath)

        # Update listing history with the new notepath:
        for listing in self.history:
            for i, notepath in enumerate(listing):
                if notepath == old_notepath:
                    listing[i] = new_notepath

        # Finally, set the last note path to point to the newly edited note:
        self.last_notepath = new_notepath

        return new_notepath

    def edit_note(self, filepath):
        modification_date = datetime.datetime.today().strftime('%Y-%m-%d')
        new_filepath = self.update_notepath(filepath, modification_date)
        return_code = subprocess.call(self.editor_cmd + ' ' + new_filepath, shell=True)
        return return_code

    def edit_date(self, filepath, date):
        modification_date = datetime.datetime.today().strftime('%Y-%m-%d')
        new_notepath = self.generate_notepath(date=date)
        shutil.copy(filepath, new_notepath)
        new_notepath = self.update_notepath(old_notepath=new_notepath, modification_date=modification_date)
        self.index.build_index()


    def tokenize(self, notepath):
        """
        Display the content of a note on the terminal.

        Using a bit of syntax here for highlighting:
        - if a line begins with "$:" or ends with ":$", the whole line is highlighted
        - if a line begins with "@:", the line is interpreted as a link to a
          reference
        - if a line begins with "http://" or "https://", the whole line is
          interpreted as a url
        - if a line begins with "#:", it is interpreted as a tag line
        - if a line contains text between two occurrences of ":::" (or whatever
          is defined a highlight_marker), that part is highlighted. If the line
          has an odd number of ":::", then the last remaining part of the line
          is highlighted.
        """
        with open(notepath, 'rt') as f:
            self.link_listing = {}

            note = parse_note(notepath)

            tokens = []

            tokens.append((Token.Tag, note.identifier))

            if note.title:
                tokens.append((Token.Punct, '\n'))
                tokens.append((Token.Title, note.title))

            if note.body:
                tokens.append((Token.Punct, '\n\n'))

                token_type = Token.Text
                i = 0

                while i < len(note.body):
                    if note.body[i:].startswith(self.highlight_markers[0]):
                        # toggle token_type on or off
                        token_type = Token.HL1 if token_type != Token.HL1 else Token.Text
                        t = len(self.highlight_markers[0])
                        i += t
                    elif note.body[i:].startswith(self.highlight_markers[1]):
                        token_type = Token.HL2 if token_type != Token.HL2 else Token.Text
                        t = len(self.highlight_markers[1])
                        i += t
                    elif note.body[i:].startswith(self.highlight_markers[2]):
                        token_type = Token.HL3 if token_type != Token.HL3 else Token.Text
                        t = len(self.highlight_markers[2])
                        i += t
                    else:
                        tokens.append((token_type, note.body[i]))
                        i += 1

            if note.links:
                tokens.append((Token.Punct, '\n\n'))
                for k, v in note.links:
                    tokens.append((Token.LinkID, '[{}] '.format(k)))
                    tokens.append((Token.Link, '{}'.format(v)))
                    tokens.append((Token.Punct, '\n'))
                    self.link_listing[k] = v
                # Delete the last newline character:
                del tokens[-1]

            if note.tags:
                if note.links:
                    tokens.append((Token.Punct, '\n'))
                else:
                    tokens.append((Token.Punct, '\n\n'))
                tokens.append((Token.Tag, ', '.join(note.tags)))

            tokens.append((Token.Punct, '\n'))
            return tokens
            # print_tokens(tokens, style=self.color_style)

    def display_note(self, notepath):
        os.system('clear')
        tokens = self.tokenize(notepath)
        print_tokens(tokens, style=self.color_style)

    def describe_note(self, notepath):
        note = parse_note(notepath)
        tokens = []
        tokens.append((Token.Attribute, '{:>20}'.format('title: ')))
        tokens.append((Token.Value, note.title + '\n'))
        tokens.append((Token.Attribute, '{:>20}'.format('date: ')))
        tokens.append((Token.Value, note.creation_date + '\n'))
        tokens.append((Token.Attribute, '{:>20}'.format('last modified: ')))
        tokens.append((Token.Value, note.modification_date + '\n'))
        tokens.append((Token.Attribute, '{:>20}'.format('tags: ')))
        tokens.append((Token.Value, ', '.join(note.tags) + '\n'))
        tokens.append((Token.Attribute, '{:>20}'.format('number of links: ')))
        tokens.append((Token.Value, str(len(note.links)) + '\n'))
        tokens.append((Token.Attribute, '{:>20}'.format('filepath: ')))
        tokens.append((Token.Value, note.filepath + '\n'))
        tokens.append((Token.Attribute, '{:>20}'.format('identifier: ')))
        tokens.append((Token.Value, note.identifier + '\n'))
        print_tokens(tokens, style=self.color_style)

    def display_note2(self, notepath):
        """
        Display the content of a note on the terminal.

        Using a bit of syntax here for highlighting:
        - if a line begins with "$:" or ends with ":$", the whole line is highlighted
        - if a line begins with "@:", the line is interpreted as a link to a
          reference
        - if a line begins with "http://" or "https://", the whole line is
          interpreted as a url
        - if a line begins with "#:", it is interpreted as a tag line
        - if a line contains text between two occurrences of ":::" (or whatever
          is defined a highlight_marker), that part is highlighted. If the line
          has an odd number of ":::", then the last remaining part of the line
          is highlighted.
        """
        with open(notepath, 'rt') as f:
            self.link_listing = {}

            note = parse_note(notepath)
            # title has been stripped, so we have to add a newline character
            # below.
            print_tokens([(Token.Title, note.title + '\n')], style=self.color_style)

            # # Make the body ends with a newline, if the body is nonempty:
            # if note.body and note.body[-1] != '\n':
            #     note.body += '\n'
            lines = note.body.split('\n')
            line_num = 0
            while line_num < len(lines):
                line = lines[line_num]

                # Is the line a link to a reference?
                if line.startswith('@:'):
                    ref = line[2:].strip()
                    index = len(self.link_listing) + 1
                    # NOTE: an extra newline is inserted below because it was
                    # stripped above
                    print_tokens([(Token.Link, str(index) + '  ' + ref + '\n')], style=self.color_style)
                    self.link_listing.append(os.path.join(self.refs, ref))
                    line_num += 1
                    continue

                # Is the line a link to a webpage?
                if line.startswith('http://') or line.startswith('https://'):
                    url = line.strip()
                    index = len(self.link_listing) + 1
                    # NOTE: an extra newline is inserted below because it was
                    # stripped above
                    print_tokens([(Token.Link, str(index) + '  ' + url + '\n')], style=self.color_style)
                    self.link_listing.append(url)
                    line_num += 1
                    continue

                # Does the line indicate a highlighted block?
                for i, marker in enumerate(self.highlight_markers):
                    if line == marker:
                        if i == 0:
                            token_type = Token.HL1
                        elif i == 1:
                            token_type = Token.HL2
                        else:  # i == 2
                            token_type = Token.HL3
                        line_num += 1
                        while line_num < len(lines) and lines[line_num] != marker:
                            print_tokens([(token_type, lines[line_num] + '\n')], style=self.color_style)
                            line_num += 1
                        # line_num += 1
                        continue

                # If neither of the above hold, the line is a regular line.
                i = 0
                marker_0_width = len(self.highlight_markers[0])
                marker_1_width = len(self.highlight_markers[1])
                while i < len(line):
                    if line[i:].startswith(self.highlight_markers[0]):
                        j = line[i + marker_0_width:].find(self.highlight_markers[0])
                        if j > -1:
                            print_tokens([(Token.Blue, line[i + marker_0_width: i + marker_0_width + j])], style=self.color_style)
                            i = i + marker_0_width + j + marker_0_width
                        else:
                            print_tokens([(Token.Blue, line[i + marker_0_width:])], style=self.color_style)
                            break
                    elif line[i:].startswith(self.highlight_markers[1]):
                        j = line[i + marker_1_width:].find(self.highlight_markers[1])
                        if j > -1:
                            print_tokens([(Token.Yellow, line[i + marker_1_width: i + marker_1_width + j])], style=self.color_style)
                            i = i + marker_1_width + j + marker_1_width
                        else:
                            print_tokens([(Token.Yellow, line[i + marker_1_width:])], style=self.color_style)
                            break
                    else:
                        print_tokens([(Token, line[i])], style=self.color_style)
                        i += 1
                # Add the newline that was lost when splitting the body on
                # \n.
                line_num += 1
                print()
            if note.tags:
                print_tokens([(Token.Tag, ', '.join(note.tags) + '\n')], style=self.color_style)

    @staticmethod
    def delete_note(notepath):
        """Delete a note"""
        if os.path.exists(notepath):
            os.remove(notepath)

    def filter_notes(self, notepaths=[], criteria={}):
        """
        Given a list of filepaths of notes and a dictionary of criteria, onlu
        keep those notes that meet the criteria.

        NOTE: Filter notes based on days with filter_notes_by_date() because
        that is a lot faster.

        Parameters
        ----------
        notepaths : list
            A list of filepaths representing notes.
        criteria : dict
            A dictionary representing various criteria.

        Returns
        -------
        result : list
            A list of note that meet the criteria.

        """
        result = []
        N = len(notepaths)

        # NOTE: by default, the case of letters is ignored in title, body, and
        # tags. But this can be switched off as option:
        if criteria.get('ignore-case', True):
            if 'words' in criteria:
                criteria['words'] = [word.lower() for word in criteria['words']]
            if 'no-words' in criteria:
                criteria['no-words'] = [word.lower() for word in criteria['no-words']]
            if 'tags' in criteria:
                criteria['tags'] = [tag.lower() for tag in criteria['tags']]

        for i, notepath in enumerate(notepaths):
            self._print_progress_bar(i, N)
            note = parse_note(notepath)

            # most pattern matching is done to the whole content of a note,
            # regardless of whether it is title, body, or tags. So let's define
            # a variable for that:
            content = note.title + '\n' + note.body + '\n' + '#: ' + ' '.join(note.tags)

            tags = note.tags
            if criteria.get('ignore-case', True):
                content = content.lower()
                tags = [t.lower() for t in tags]

            # tags: if tags are given, at least one tag must appear
            if criteria.get('tags', []):
                if not set.intersection(set(criteria['tags']), set(tags)):
                    continue

            # words: all words must appear as a SUBSTRING
            # NOTE: this includes cases like 'string' in 'substring'
            # NOTE: matching is done in lower case
            if criteria.get('words', []):
                for word in criteria['words']:
                    if word not in content:
                        not_all_words_appear = True
                        break
                else:
                    not_all_words_appear = False
                if not_all_words_appear:
                    continue

            # no_words: none of these words must appear
            # NOTE: here only full words are considered. For example, 'substring'
            # does not count as an occurrence of 'string'.
            # NOTE: matching is done preserving the case of letters
            if criteria.get('no-words', []):
                if set.intersection(set(criteria['no-words']), set(content.split())):
                    continue

            result.append(notepath)
        return result

    def _print_progress_bar(self, i, N):
        """Print progressbar.

        Useful when going through a long and slow loop, such as when iterating
        through notes that are parsed and searched.
        """
        full = N // 100
        if i % 100 == 0:
            remain = i // 100
            # http://stackoverflow.com/a/5419488
            print((full - remain) * '.' + remain * ' ' + '\r', end='')
            # print('|\|/'[(i // 100) % 4] + '\r', end='')
            sys.stdout.flush()

    def print_note_listing(self, notepaths):
        """
        Print a list of note in a nicely formatted way.

        Parameter
        ---------
        notepaths : list
            A list of filepaths of notes.
        """

        # fmt is of the form:
        # <index>    YYYY-MM-DD aaa    <title>
        fmt = '{:>4}    {:<14}    {}'
        N = len(notepaths)

        # If note_list is empty, do not print anything
        if N == 0:
            return None
        else:
            print()
        #     print(fmt.format('index', 'date', 'title'))

        for i, notepath in enumerate(notepaths):
            note = parse_note(notepath)
            print(fmt.format(str(N - 1 - i), note.creation_date, note.title))
            # Flush stdout after each print statement so that the listing
            # appears smooth to the user:
            sys.stdout.flush()

        self.history.append(notepaths)
        infobar = '\nlisting: {}\ttotal: {}'.format(len(self.history), N)
        print(infobar)

    def print_tag_listing(self, sort_by_count=False):
        tags = self.count_tags()
        if not tags:
            return None
        max_len = max(len(t) for t in tags)
        # print()
        if sort_by_count:
            items = sorted(tags.items(), key=lambda x: x[1])
        else:
            items = sorted(tags.items(), key=lambda x: x[0].lower())

        for tag, count in items:
            tokens = []
            # tokens.append((Token.Tag, ('  {:<}').format(tag)))
            tokens.append((Token.Tag, ('{:<' + str(max_len + 2) + '}').format(tag)))
            tokens.append((Token, '{:<3} '.format(count)))
            print_tokens(tokens, style=self.color_style)
            print()
        print('\ntotal:', len(tags))


    def search_by_keywords(self, query):
        self.index.update_index()
        res = defaultdict(set)
        for word in query:
            word = word.lower()
            for token in self.index.index.keys():
                if word in token.lower():
                    res[word].update(set(self.index[token]))
        if not res.values():
            return []
        else:
            note_ids = reduce(set.intersection, res.values())
            notepaths = [self.identifier_to_notepath(note_id) for note_id in note_ids]
            notepaths = list(sorted(notepaths))
            return notepaths

    def filter_notes_by_tags(self, notepaths, criteria):
        """
        Filter out notes that do not have a tag in criteria.
        """

        # If criteria does not have nay tags in, let every note pass through.
        if 'tags' not in criteria:
            return notepaths

        self.tag_index.update_index()

        # Take every note whose tags include all tags in the criteria.
        res = defaultdict(set)
        for t in criteria['tags']:
            # print(self.tag_index.index)
            if t in self.tag_index.index:
                res[t].update(set(self.tag_index[t]))
        if not res.values():
            return []
        else:
            note_ids = reduce(set.intersection, res.values())
            filtered_notepaths = []
            for np in notepaths:
                _, _, _, identifier = extract_filename_info(np)
                if identifier in note_ids:
                    filtered_notepaths.append(np)
            return filtered_notepaths