PK!multidocs/__init__.pyPK!An{NNmultidocs/__main__.pyif __name__ == "__main__": import multidocs.cli multidocs.cli.main() PK!rM66multidocs/cli.pyimport argparse import os import os.path import logging def serve_webapp(args): import multidocs.web.app multidocs.web.app.run_server() def generate_html(args): import multidocs.content multidocs.content.generate_html() def main(): parser = argparse.ArgumentParser() parser.add_argument("-c", "--config") parser.add_argument("command") args = parser.parse_args() logging.basicConfig(level=logging.DEBUG) if args.config: os.environ.setdefault("MULTIDOCS_CONFIG_FILE", os.path.abspath(args.config)) os.chdir(os.path.dirname(os.environ["MULTIDOCS_CONFIG_FILE"])) if args.command == "serve": return serve_webapp(args) if args.command == "generate": return generate_html(args) raise ValueError("unknown command: %r" % args.command) PK!Liimultidocs/config.pyimport os import os.path import yaml _conf_values = None def read_config(): conf_file = os.environ.get("MULTIDOCS_CONFIG_FILE") if conf_file: if not os.path.exists(conf_file): raise RuntimeError("MULTIDOCS_CONFIG_FILE %r does not exist" % conf_file) if not os.path.isfile(conf_file): raise RuntimeError("MULTIDOCS_CONFIG_FILE %r is not a file" % conf_file) with open(conf_file) as fh: return yaml.safe_load(fh) def get_config(): global _conf_values if _conf_values is None: _conf_values = read_config() return _conf_values PK!*Xmultidocs/content/__init__.pyimport os import os.path import re import bleach.linkifier from multidocs.globals import config, settings, j2 from multidocs.search import get_search from multidocs.sources import download_source from multidocs import entities from . import markdown def _linkify_callback(attrs, new=False): if not new: return attrs # try to filter out things that might look like URLs but aren't if ( not attrs["_text"].startswith(("http:", "https:", "www.")) and "/" not in attrs["_text"] ): return None return attrs linker = bleach.linkifier.Linker(callbacks=[_linkify_callback]) def guess_file_title(file_name): parts = re.split(r"[_.-]+", file_name.split(".")[0]) return " ".join(parts).capitalize() def iter_path(node): yield from node.children for child in node.children: yield from iter_path(child) def get_title_and_body(path): title = None with open(path) as fh: body = fh.read() if path.endswith(".md"): title, body = markdown.extract_title(body) return title, body def page_to_html(page, source=None): source = source or page.source if source.path.endswith(".md"): html = markdown.page_to_html(page) else: raise ValueError("unknown page source type: %r" % source) html = linker.linkify(html) return html def generate_html(): sources = {} for source_cfg in config["sources"]: source = download_source(**source_cfg) sources[source] = entities.ContentSource(source) root = entities.ContentRoot(sources.values()) contents = {None: root} # convert source objects to content objects for source, content_source in sources.items(): contents[source] = content_source for path in iter_path(source): kwargs = dict( parent=contents.get(path.parent), path=content_source.path + "/" + path.path, slug=path.basename, source=path, ) if isinstance(path, entities.SourceDirectory): cls = entities.Directory else: cls = entities.Page kwargs["path"] = os.path.splitext(kwargs["path"])[0] + ".html" kwargs["title"], kwargs["body"] = get_title_and_body( os.path.join(source.root_path, path.path) ) if not kwargs.get("title"): kwargs["title"] = guess_file_title(path.basename) content = cls(**kwargs) contents[path] = content sources = list(sources.values()) # write HTML from content objects for content in contents.values(): content_path = os.path.join(settings.target_dir, content.path) if content.is_dir: content_dir = content_path content_path = os.path.join(content_path, "index.html") else: content.html = page_to_html(content) content_dir = os.path.dirname(content_path) html = j2.get_template("content.html.j2").render(content=content, root=root) if not os.path.exists(content_dir): os.makedirs(content_dir) with open(content_path, "w+") as fh: fh.write(html) # store content in the search index get_search().index_contents(root) # store the sidebar HTML separately so it can be used in dynamic pages sidebar_path = os.path.join(settings.target_dir, "_sidebar.html") html = j2.get_template("sidebar.html.j2").render(root=root) with open(sidebar_path, "w+") as fh: fh.write(html) def get_sidebar_html(): sidebar_path = os.path.join(settings.target_dir, "_sidebar.html") if os.path.exists(sidebar_path): with open(sidebar_path) as fh: return fh.read() PK!|ʲ^^multidocs/content/markdown.pyfrom CommonMarkExtensions.tables import ParserWithTables, RendererWithTables from .utils import transform_url parser = ParserWithTables() renderer = RendererWithTables() def extract_title(markdown): """ Given markdown text, return the title and body. """ body = None title = None prev_line = None for line in markdown.splitlines(): # prevent comments in code blocks from becoming titles if line.startswith("```"): break if line.startswith("# "): title = line[2:].strip() elif prev_line and line.count("=") >= len(prev_line) / 2: title = prev_line if title: idx = markdown.index(line) + len(line) body = markdown[idx:].strip() break prev_line = line.strip() if not body: body = markdown return title, body.strip() def page_to_html(page): ast = parser.parse(page.body) for node, entering in ast.walker(): if entering and node.t == "link": node.destination = transform_url(node.destination, page) return renderer.render(ast) PK!,+multidocs/content/utils.pyimport os.path from urllib.parse import urlsplit, urlunsplit def transform_url(url, page, source=None): source = source or page.source urlparts = urlsplit(url) # don't do anything with absolute URLs if urlparts.netloc or urlparts.path.startswith("/"): return url # if URL has more .. than page.path has slashes, the relative URL goes # beyond the scope of the source root which means it's not part of the # generated HTML, so we need to fall back on the source URL if source and url.split("/").count("..") >= len(page.path.split("/")): urlparts = urlsplit(source.url + "/" + urlparts.path) new_path = os.path.normpath(urlparts.path) return urlunsplit((urlparts.scheme, urlparts.netloc, new_path, "", "")) # at this point we know we're dealing with a relative URL which is inside # the multidocs directory, so we can safely replace md with html if url.endswith(".md"): url = url[:-3] + ".html" return url PK!1Le e multidocs/entities.pyimport os.path import slugify from multidocs.globals import settings def _get_source(obj, source=None): if source is None: source = obj.parent while not isinstance(source, Source): if source is None: return None source = source.parent return source class Source: def __init__(self, url, root_dir, title=None, slug=None): self.url = url self.root_dir = root_dir self.title = title or os.path.basename(url) self.slug = slug or slugify.slugify(self.title) self.path = self.slug self.abspath = os.path.join(settings.source_dir, self.path, self.root_dir) self.children = set() def __repr__(self): return "<%s %r>" % (self.__class__.__name__, self.url) class GitSource(Source): def __init__(self, *args, branch=None, **kwargs): super().__init__(*args, **kwargs) self.branch = branch class Path: def __init__(self, path, parent=None): self.path = path self.basename = os.path.basename(path) self.dirname = os.path.dirname(path) self.children = set() self.parent = parent if parent: parent.children.add(self) def __repr__(self): return "<%s %r>" % (self.__class__.__name__, self.path) def __gt__(self, other): return self.path > other.path class SourcePath(Path): def __init__(self, path, parent=None, source=None): super().__init__(path, parent=parent) self.source = _get_source(self, source) self.abspath = os.path.join(self.source.abspath, self.path) class SourceFile(SourcePath): is_dir = False class SourceDirectory(SourcePath): is_dir = True class Content(Path): def __init__(self, path, source, title=None, parent=None, slug=None): super().__init__(path, parent=parent) self.source = source self.title = title if slug: self.slug = slug else: if title: self.slug = slugify.slugify(self.title) else: self.slug = os.path.basename(self.path) def __gt__(self, other): return (self.title or self.slug) > (other.title or other.slug) class Directory(Content): is_dir = True class ContentRoot(Directory): def __init__(self, sources, title=None): self.path = self.slug = "" self.title = title or "Index" self.children = sources self.source = None class ContentSource(Directory): def __init__(self, source): super().__init__( source.slug, source, title=source.title, parent=None, slug=source.slug ) class Page(Content): is_dir = False def __init__(self, path, source, body, title=None, parent=None, slug=None): super().__init__(path, source, title=title, parent=parent, slug=slug) self.body = body PK!ܿmultidocs/globals.pyimport multidocs.settings import multidocs.config import multidocs.jinja2 __all__ = ["settings", "config", "j2"] settings = multidocs.settings.get_settings() config = multidocs.config.get_config() j2 = multidocs.jinja2.get_jinja2_env() PK!ۯmultidocs/jinja2.pyimport datetime import jinja2 def get_jinja2_env(): j2 = jinja2.Environment( loader=jinja2.PackageLoader(__name__, "templates"), undefined=jinja2.StrictUndefined, ) j2.globals["now"] = datetime.datetime.now() return j2 PK!a<  multidocs/search.pyimport logging import os import os.path import shutil import whoosh.index import whoosh.fields import whoosh.qparser from multidocs.globals import settings log = logging.getLogger(__name__) schema = whoosh.fields.Schema( title=whoosh.fields.TEXT(stored=True), path=whoosh.fields.ID(stored=True), src_path=whoosh.fields.ID(stored=True), body=whoosh.fields.TEXT, ) class Result: def __init__(self, path, src_path, title, highlight=None): self.path = path self.src_path = src_path self.title = title self.highlight = highlight class Search: def __init__(self, path): self.idx_path = path if not os.path.exists(self.idx_path): self.index = self._create_index() try: self.index = whoosh.index.open_dir(self.idx_path) except whoosh.index.EmptyIndexError: log.warning("error reading whoosh index") self._create_index() def _create_index(self): log.info("re-creating search index") if os.path.exists(self.idx_path): shutil.rmtree(self.idx_path) os.makedirs(self.idx_path) return whoosh.index.create_in(self.idx_path, schema) def index_contents(self, root): # re-create the index, wiping existing data self.index = self._create_index() writer = self.index.writer() def _index(content): if hasattr(content, "body"): log.debug("adding content to search index: %r", content) writer.add_document( title=content.title, path=content.path, src_path=content.source.abspath, body=content.body, ) for child in content.children: _index(child) _index(root) log.info("committing search index") writer.commit() def search(self, search_for, num_results=5): qp = whoosh.qparser.QueryParser("body", self.index.schema) query = qp.parse(search_for) ret = [] with self.index.searcher() as searcher: for hit in searcher.search(query)[:num_results]: highlight = src_path = None if "src_path" in hit: src_path = hit["src_path"] with open(src_path) as fh: contents = fh.read() highlight = hit.highlights("body", text=contents) result = Result( path=hit["path"], title=hit["title"], highlight=highlight, src_path=src_path, ) ret.append(result) return ret def get_search(): return Search(settings.source_dir + "/_idx") PK!&Tmultidocs/settings.pyimport os import os.path from .config import get_config class Undefined: def __bool__(self): return False def __str__(self): return "UNDEFINED" def __repr__(self): return "" UNDEF = Undefined() class Settings: def __init__(self, data): object.__setattr__(self, "_data", data) def __getattr__(self, key): return self._data.get(key, UNDEF) def get_settings(): # default values values = {} # config file config = get_config() if config and config.get("settings"): for key, val in config["settings"].items(): values[key.lower()] = val # environment variables for key, val in os.environ.items(): if not key.startswith("MULTIDOCS_"): continue key = key[7:].lower() values[key] = val # convertions for key, val in values.items(): if key.endswith(("_dir", "_path")): values[key] = os.path.abspath(val) return Settings(values) PK! ɰmultidocs/sources/__init__.pyimport importlib def download_source(**kwargs): source_type = kwargs.pop("type", None) # todo: guess type based on url? source_module = importlib.import_module("%s.%s" % (__name__, source_type)) return source_module.download_source(**kwargs) PK!1Q@multidocs/sources/git.pyimport os.path import subprocess from multidocs.globals import settings from multidocs.entities import GitSource from .path import populate_source_from_path def github_cb(path, source): path_type = "tree" if path.is_dir else "blob" path_in_repo = os.path.join(source.root_dir, path.path) path.url = f"{source.url}/{path_type}/{source.branch}/{path_in_repo}" if not path.is_dir: path.edit_url = f"{source.url}/edit/{source.branch}/{path_in_repo}" def populate_urls(node, source, url_cb): for child in node.children: url_cb(child, source) populate_urls(child, source, url_cb) def populate_source_urls(source): source_url = source.url.replace("ssh://", "").replace( "git@github.com", "https://github.com" ) if source_url.startswith("https://github.com"): populate_urls(source, source, github_cb) def download_source(url, **kwargs): env = {} ssh_key = kwargs.pop("ssh_key", None) or settings.git_ssh_key if ssh_key: env["GIT_SSH_COMMAND"] = "ssh -i %s" % ssh_key source = GitSource(url, **kwargs) git_path = os.path.join(settings.source_dir, source.slug) if os.path.exists(os.path.join(git_path, ".git")): res = subprocess.run( ["git", "-C", git_path, "remote", "-v"], env=env, capture_output=True, encoding="utf-8", check=True, ) # todo: this needs to be improved if url not in res.stdout: print(res.stdout) raise RuntimeError("url not in git remote") subprocess.run(["git", "-C", git_path, "pull", "-q"], env=env, check=True) else: subprocess.run(["git", "clone", "-q", url, git_path], env=env, check=True) if not source.branch: res = subprocess.run( ["git", "-C", git_path, "rev-parse", "--abbrev-ref", "HEAD"], env=env, capture_output=True, encoding="utf-8", check=True, ) source.branch = res.stdout.strip() populate_source_from_path(source, os.path.join(git_path, source.root_dir)) populate_source_urls(source) return source PK!] multidocs/sources/path.pyimport os import os.path from multidocs.entities import SourceFile, SourceDirectory def populate_children(parent, path, root_path): with os.scandir(path) as entries: for entry in entries: if entry.name.startswith("."): continue relpath = os.path.relpath(entry.path, root_path) if entry.is_dir(follow_symlinks=False): sd = SourceDirectory(relpath, parent=parent) parent.children.add(sd) populate_children(sd, entry.path, path) else: sf = SourceFile(relpath, parent=parent) parent.children.add(sf) def populate_source_from_path(source, path): source.root_path = path populate_children(source, path, path) PK!#multidocs/templates/content.html.j2{% extends 'layout.html.j2' %} {% macro content_links() %} {% endmacro %} {% macro render_children(children, deep=True) %} {% endmacro %} {% block title %}{{ content.title }}{% endblock %} {% block content %} {{ content_links() }}

{{ content.title | capitalize }}

{% if content.is_dir %} {{ render_children(content.children, deep=False) }} {% else %} {{ content.html | safe }} {% endif %}
{% endblock %} {% block js_body %} {% endblock %} PK!muHH"multidocs/templates/layout.html.j2 {% block title %}{% endblock %} | multidocs
{% if sidebar_html is defined %} {{ sidebar_html | safe }} {% else %} {% include "sidebar.html.j2" %} {% endif %}
{% block content %}{% endblock %}
{% block js_body %}{% endblock %} PK! :EE"multidocs/templates/search.html.j2{% extends 'layout.html.j2' %} {% block title %}Search results{% endblock %} {% block content %}

Search results for "{{ query }}"

{% for result in results %}

{{ result.title }}

{% if result.highlight %}

{{ result.highlight }}

{% endif %}
{% else %} No results! {% endfor %}
{% endblock %} PK!ߪ%%#multidocs/templates/sidebar.html.j2{% macro render_sidebar_children(children, class='') %} {% endmacro %} PK!iOOmultidocs/web/app.pyimport os.path import flask import multidocs.globals import multidocs.search import multidocs.web.auth import multidocs.web.search import multidocs.web.static def get_app(): static_folder = os.path.join(os.path.dirname(__file__), "static") app = flask.Flask("multidocs", static_folder=static_folder) app.secret_key = multidocs.globals.settings.secret_key app.search = multidocs.search.get_search() app.auth = multidocs.web.auth.get_authenticator(app) app.register_blueprint(multidocs.web.auth.blueprint) app.register_blueprint(multidocs.web.search.blueprint) app.register_blueprint(multidocs.web.static.blueprint) return app def run_server(host="0.0.0.0", port=5000, debug=False): from werkzeug.serving import run_simple run_simple(host, port, get_app(), use_reloader=debug, use_debugger=debug) PK!3multidocs/web/auth/__init__.pyimport functools import importlib import flask from flask import current_app from flask.blueprints import Blueprint from multidocs.globals import settings class Authenticator: is_oauth = False def is_logged_in(self): raise NotImplementedError() def is_allowed(self): raise NotImplementedError() def login(self): raise NotImplementedError() def requires_login(func): """ Route decorator that enforces login. """ if not settings.auth_required: return func @functools.wraps(func) def wrapper(*args, **kwargs): auth = flask.current_app.auth if not auth.is_logged_in(): return login() if not auth.user_is_authorized(): logout() return "You do not have access to this resource.", 403 return func(*args, **kwargs) return wrapper blueprint = Blueprint("auth", __name__) @blueprint.route("/login") def login(): if settings.auth_required: return current_app.auth.login() return flask.redirect(flask.url_for("index")) @blueprint.route("/login/authorized") def authorized(): if settings.auth_required: if not current_app.auth.is_oauth: raise RuntimeError( "attempting oauth authorized callback without" "an oauth-enabled Authenticator!" ) current_app.auth.oauth_authorize() return flask.redirect(flask.url_for("index")) @blueprint.route("/logout") def logout(): current_app.auth.logout() return "You have been logged out." def get_authenticator(app=flask.current_app): mod = importlib.import_module(__name__ + "." + settings.auth_type) return mod.get_authenticator(app) PK!A߮" " multidocs/web/auth/github.pyimport fnmatch import flask from flask_oauthlib.client import OAuth from . import Authenticator from multidocs.globals import settings def _matches_conf(items, configs): if not isinstance(configs, (list, tuple, set)): configs = (configs,) return any( (any((fnmatch.fnmatch(item, conf) for item in items)) for conf in configs) ) class GithubAuthenticator(Authenticator): is_oauth = True session_key = "github_token" def __init__(self, app): self.gh = OAuth(app).remote_app( "github", consumer_key=settings.auth_github_key, consumer_secret=settings.auth_github_secret, access_token_method="POST", access_token_url="https://github.com/login/oauth/access_token", authorize_url="https://github.com/login/oauth/authorize", base_url="https://api.github.com/", request_token_params={"scope": "read:org"}, request_token_url=None, ) self.gh.tokengetter(self.get_github_oauth_token) def get_github_oauth_token(self): return flask.session.get(self.session_key) def is_logged_in(self): return bool(flask.session.get(self.session_key)) def is_allowed(self): if settings.auth_github_orgs not in ("all", "*"): org_names = {o["login"] for o in self.gh.get("user/orgs").data} if not _matches_conf(org_names, settings.auth_github_orgs): return False if settings.auth_github_teams not in ("all", "*"): team_names = {o["name"] for o in self.gh.get("user/teams").data} if not _matches_conf(team_names, settings.auth_github_teams): return False return True def login(self): return self.gh.authorize(callback=flask.url_for("authorized", _external=True)) def oauth_authorize(self): resp = self.gh.authorized_response() if not resp or not resp.get("access_token"): flask.abort( 403, "
".join( ( "Access denied! Reason: %s" % flask.request.args["error"], "Description: %s" % flask.request.args["error_description"], flask.request.args["error_uri"], ) ), ) flask.session[self.session_key] = (resp["access_token"], "") def logout(self): flask.session.pop(self.session_key, None) def get_authenticator(app=flask.current_app): return GithubAuthenticator(app) PK!~multidocs/web/search.pyimport flask from flask.blueprints import Blueprint from multidocs.content import get_sidebar_html from .auth import requires_login blueprint = Blueprint("search", __name__) @blueprint.route("/search") @requires_login def search(): query = flask.request.args.get("for") results = flask.current_app.search.search(query, num_results=15) sidebar_html = get_sidebar_html() return flask.render_template( "search.html.j2", query=query, results=results, sidebar_html=sidebar_html ) PK!jB&multidocs/web/static/button-closed.pngPNG  IHDR w&tEXtSoftwareAdobe ImageReadyqe<IDATxڄ Gу v")(%lAn{ .;}ȼ/"z7&Ns1cYk1=per1 }#(n~#_3PiƶmטsZf~ta g4SJ7 x>2hx0[c,mIENDB`PK!gj$multidocs/web/static/button-open.pngPNG  IHDR w&tEXtSoftwareAdobe ImageReadyqe<IDATxڔQ ! Dc//!ٛhFfZ?JCہ Cz#R&+Qe朷9Lrɽl*.-("M:(hc 4N!Xϯ { if (node.classList.contains('collapsibleList')){ applyTo(node, true); if (!doNotRecurse){ [].forEach.call(node.getElementsByTagName('ul'), subnode => { subnode.classList.add('collapsibleList') }); } } }) } // Makes the specified list collapsible. The parameters are: // // node - the list element // doNotRecurse - true if sub-lists should not be made collapsible function applyTo(node, doNotRecurse){ [].forEach.call(node.getElementsByTagName('li'), li => { if (!doNotRecurse || node === li.parentNode){ li.style.userSelect = 'none'; li.style.MozUserSelect = 'none'; li.style.msUserSelect = 'none'; li.style.WebkitUserSelect = 'none'; li.addEventListener('click', handleClick.bind(null, li)); toggle(li); } }); } // Handles a click. The parameter is: // // node - the node for which clicks are being handled function handleClick(node, e){ let li = e.target; while (li.nodeName !== 'LI'){ li = li.parentNode; } if (li === node){ toggle(node); } } // Opens or closes the unordered list elements directly within the // specified node. The parameter is: // // node - the node containing the unordered list elements function toggle(node){ const open = node.classList.contains('collapsibleListClosed'); const uls = node.getElementsByTagName('ul'); [].forEach.call(uls, ul => { let li = ul; while (li.nodeName !== 'LI'){ li = li.parentNode; } if (li === node){ ul.style.display = (open ? 'block' : 'none'); } }); node.classList.remove('collapsibleListOpen'); node.classList.remove('collapsibleListClosed'); if (uls.length > 0){ node.classList.add('collapsibleList' + (open ? 'Open' : 'Closed')); } } return {apply, applyTo, toggle}; })(); CollapsibleLists.apply(); PK!+multidocs/web/static/list-item-contents.pngPNG  IHDRiMtEXtSoftwareAdobe ImageReadyqe<5IDATxͱ d؋`oҜhf;YikYIENDB`PK!G̓,multidocs/web/static/list-item-last-open.pngPNG  IHDRiMtEXtSoftwareAdobe ImageReadyqe<CIDATxb?:غu+HaţZ code { background: #f3f3f3; border: 1px solid #ddd; padding: 4px 8px; max-width: 100%; overflow-x: auto; display: block; } /* COLLAPSIBLE LISTS - mostly copy/pasted */ .treeView { -moz-user-select:none; position:relative; } .treeView ul { margin:0 0 0 -1.5em; padding:0 0 0 1.5em; } ul.treeView { padding: 0; margin: 0; } .treeView ul ul { background:url('list-item-contents.png') repeat-y left; } .treeView li:last-child > ul { background-image:none; } .treeView li { margin:0; padding:0; background:url('list-item-root.png') no-repeat top left; list-style-position:inside; list-style-image:url('button.png'); cursor:auto; } .treeView li.collapsibleListRoot { background:url('list-item-root.png') no-repeat top left !important; } .treeView li.collapsibleListOpen { list-style-image:url('button-open.png'); cursor:pointer; } .treeView li.collapsibleListClosed { list-style-image:url('button-closed.png'); cursor:pointer; } .treeView li li { background-image:url('list-item.png'); padding-left:1.5em; } .treeView li:last-child { background-image:url('list-item-last.png'); } .treeView li.collapsibleListOpen { background-image:url('list-item-open.png'); } .treeView li.collapsibleListOpen:last-child { background-image:url('list-item-last-open.png'); } PK!S|ttmultidocs/web/static.pyimport os.path import flask from flask.blueprints import Blueprint from multidocs.globals import settings from .auth import requires_login blueprint = Blueprint("static", __name__) @blueprint.route("/") @requires_login def serve_html(path): full_path = os.path.join(settings.target_dir, path) if os.path.isdir(full_path): path += "/index.html" if not os.path.exists(path) and not path.endswith(".html"): path = path + ".html" return flask.send_from_directory(settings.target_dir, path, cache_timeout=-1) @blueprint.route("/") def index(): return serve_html("index.html") PK!HH{*0*multidocs-0.1.1.dist-info/entry_points.txtN+I/N.,()-)LO.s2r3PK!HW"TTmultidocs-0.1.1.dist-info/WHEEL A н#J."jm)Afb~ ڡ5 G7hiޅF4+-3ڦ/̖?XPK!HUo#t"multidocs-0.1.1.dist-info/METADATASn0 }WĊiaIVlZalMGYVX"yHsb%ȾҚF|BcQQl!!6Z  zh4QD[e;lK X_|Z|`3Y #WM/ʬ<&zfT}L^Z;Fz mK@yy1g ٵ!ȵDzmT0j ߥx~?|2 EYÛ Ҿ?zDŸ#CJyOtHZ?)CdVJ.i*8̏y=8U{jTSu۵oVh c> {qJ/]ڵ$fw5$ ɴ[Xò (|Y n32Hle˕YB[/1`(M|Rug|4! (s >#wAVX%=ܵL4AeoKji e4QFI]xv U[=gը\GPK!HRA multidocs-0.1.1.dist-info/RECORDǶZ}<8 JFE&>}[nUsα{1aWC .P(N #>(x0bpMmwgpt jCU~^z+2I`GOieEFl v]'yA$20سxA>։2*)T9XjDi$O77+Xw6`,/ΩZ"Nz6PȻd@B~@\w7v_{,8ozms(LaLܬSОB7jwELFEXz%R2ŢYtC"c=ϊN uyoH%atN3!m<+Kܚ[88wxн́hz+;p \j ͙2(xJt;P]ev;;:Ire#ED:y(lB۝=B",-U_PNgwjw{Œpm txZ x8w:}!00~[#zXNIo*#y#FJ<'%7hm$򉜨p/1{jIe+/{t>6B(B!o[2SH՞ÆMYme+#D=U:mr-TWjM M3U:4;T~U%E=Dqo:-{ J'PP VEߖ(XV1FةvI }3(WB?c-JP