Source code for self_wiki.wiki

"""
Contains wiki-related stuff.

For instance, :py:class:Page may be used to manipulate .md files on disk.
"""
import logging
from datetime import datetime
from os import listdir, makedirs, stat, walk
from os.path import dirname, exists, isdir, join as pjoin, sep as psep
from typing import Dict, List, Optional, Union

from markdown import Markdown

MD_EXTS = [
    "extra",
    "admonition",
    "codehilite",
    "meta",
    "sane_lists",
    "smarty",
    "toc",
    "wikilinks",
]

logger = logging.getLogger(__name__)
repository = None


[docs]class Page: """ Container for a markdown file. Basically, all manipulation on .md files should go via this """ converter = Markdown(extensions=MD_EXTS, output_format="html5") logger.info("Enabled markdown extensions: %s", ", ".join(MD_EXTS)) def __init__(self, path, root="", level=0, shallow=False): """ Create a new Page representation. :param root: an optional path to use as root. used for Page.relpath. :param path: path this page's data on disk :param level: How deep are we in the rabbit hole? mainly used to not recurse a whole directory tree """ if root != "" and root in path: path = path[len(root) :] # noqa self.root = root self._path = path self.level = level if path[-3:] != ".md": self._path = self._path + ".md" self.markdown = "" self.meta = None self.subpages = [] self.load(not shallow)
[docs] def load(self, load_children=False): """ Load the markdown data from disk. Also sets object properties according to filesystem state. """ if not exists(self.path): return with open(self.path, "r") as markdown_file: logger.debug( "Found existing page content at %s. Loading at level %d", self.path, self.level, ) self.markdown = markdown_file.read() # We need a way to make sure we don't read an entire directory tree if self.level > 0 or not load_children: return subpages_dir = self.path[:-3] # remove the .md if exists(subpages_dir) and isdir(subpages_dir): for markdown_file in listdir(subpages_dir): if isdir(markdown_file) or not markdown_file.endswith(".md"): continue logger.debug( "Found child page %s at level %d", markdown_file, self.level, ) self.subpages.append( Page( pjoin(subpages_dir, markdown_file), root=self.root, level=self.level + 1, ) )
[docs] def save(self): """ Persist the Page object on disk and update the recent files list. note: this method does not update a RecentFileManager object! """ if psep in self.path and not exists(dirname(self.path)): makedirs(dirname(self.path)) with open(self.path, "w+") as save_file: save_file.write(self.markdown) # update self.meta self.render() if repository is not None: repository.index.add([self.path]) if repository.index.diff: logger.info("Adding changes to page %s to git", self.title) repository.index.commit(message="Change {}".format(self.title))
@property def path(self) -> str: """Return the full path to the markdown document.""" return pjoin(self.root, self._path) @property def relpath(self) -> str: """Return the page's path, relative to the configured content root.""" return self._path @property def title(self) -> str: """ Return the title of the page. This is computed either from the markdown's metadata ('Title:' as one of the pages' header), or the first level 1 header, or the pages' path """ if not self.meta: self.render() if "title" in self.meta: return self.meta["title"][0] for line in self.markdown.split("\n"): if line.startswith("# "): return line[2:] return self.relpath[:-3]
[docs] def render(self) -> str: """Render the markdown to HTML, using the object's converter.""" html = self.converter.convert(self.markdown) self.meta = self.converter.Meta # pylint: disable=E1101 return html
[docs]class RecentFileManager: """Represents a collection of files, with their age attached.""" DEFAULT_LIMIT = 20
[docs] @classmethod def get_recent_files( cls, directory: str, limit: Optional[int] = DEFAULT_LIMIT, wanted_extensions: Optional[List[str]] = None, ) -> List[Dict[str, Union[str, int]]]: """ Return the list of recent files. This list is sorted by modification time as a UNIX timestamp (recent first), with an optional *limit*. :param directory: Base directory for the search :param limit: number of results to return. May be None to return all results. :param wanted_extensions: A list of file extensions we want. If None, ['md'] is used. :return: a dictionary list with, where each dict has the following keys: path, mtime """ files = [] if not wanted_extensions: wanted_extensions = ["md"] for path, dirnames, filenames in walk(directory): dirnames[:] = [ d for d in dirnames if d != ".git" ] # remove git dir(s) for fname in filenames: if fname == "todos.json": continue if ( wanted_extensions and fname.rsplit(".", maxsplit=1)[-1] not in wanted_extensions ): continue stat_result = stat(pjoin(path, fname)) files.append( {"path": pjoin(path, fname), "mtime": stat_result.st_mtime} ) sorted_files = sorted(files, key=lambda x: x["mtime"], reverse=True) if limit is None: return sorted_files return sorted_files[:limit]
def __init__( self, root: str, wanted_extensions: Optional[List[str]] = None, limit: Optional[int] = DEFAULT_LIMIT, ): """ Create a new recent file manager. :param root: The root path we want to find recent files in :param wanted_extensions: a whitelist of file extensions we want, without the '.'. Defaults to ['md']. See get_recent_files. :param limit: a limit. May be None to read everything. """ self._root = root self._file_list = RecentFileManager.get_recent_files( directory=root, limit=limit, wanted_extensions=wanted_extensions ) @property def root(self) -> str: """Return the path we consider as root.""" return self._root
[docs] def re_scan( self, limit: Optional[int] = None, wanted_extensions: Optional[List[str]] = None, ): """ Re-scan the defined content root. :param wanted_extensions: a list of file extensions we want to include. Specifying [''] will include everything. Defaults to ['md'] :param limit: limit the number of results to this :return: """ self._file_list = RecentFileManager.get_recent_files( directory=self.root, limit=limit, wanted_extensions=wanted_extensions, )
[docs] def update(self, path: str): """ Update the recency of the file designated by :param path:. Note that said file is not required to exist. :param path: path to the file, relative to RecentFileManager.root, or not. """ if not path.startswith(self._root): path = pjoin(self._root, path) now = datetime.now() self.delete(path) self._file_list.insert(0, {"path": path, "mtime": now.timestamp()})
[docs] def get( self, limit: Optional[int] = None ) -> List[Dict[str, Union[str, int]]]: """Return up to *limit* recent items.""" if limit == 0: raise ValueError( "it doesn't make any sense to try to get an empty list..." " call list() yourself" ) if limit is None or len(self._file_list) < limit: limit = len(self._file_list) return list(self._file_list[:limit])
[docs] def delete(self, path: str): """ Delete :param path: from the recent files. :param path: The exact path we should forget. """ self._file_list[:] = [ d for d in self._file_list if d.get("path") != path ]