pax_global_header00006660000000000000000000000064152242124070014511gustar00rootroot0000000000000052 comment=d9f6f6c631f43dc0097cb7655c140c31161ea7d4 hyperorg/000077500000000000000000000000001522421240700127145ustar00rootroot00000000000000hyperorg/.gitignore000066400000000000000000000006761522421240700147150ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to __pycache__ .coverage notes build .*cache *.egg-info _*.py \#*.py\# .\#*.py example/html .DS_Store .venv venv output/ hyperorg/CHANGELOG.md000066400000000000000000000053071522421240700145320ustar00rootroot00000000000000 # Changelog [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) ## [0.2.0] - 2026-07-10 ### Changed - **Breaking**: Increased minimal Python version to 3.11 - Modified rendered result of invalid links ([#30](https://codeberg.org/buhtz/hyperorg/issues/30)) - Replace existing style.css when generated by an outdated version of Hyperorg ([#135](https://codeberg.org/buhtz/hyperorg/issues/135)) - Replace `pycodestyle` with `flake8` ([#158](https://codeberg.org/buhtz/hyperorg/issues/158)) ### Added - LaTeX math formatting for inline and display math using MathJax ([#154](https://codeberg.org/buhtz/hyperorg/issues/154), [#155](https://codeberg.org/buhtz/hyperorg/issues/155), Michael Olie [@MikeEmpire]) - Detect tables (org and tables.el style) but render them as code blocks ([#141](https://codeberg.org/buhtz/hyperorg/issues/141)) - Description of branching model ([#126](https://codeberg.org/buhtz/hyperorg/issues/126)) - Description of project characteristics ([#118](https://codeberg.org/buhtz/hyperorg/issues/118)) - Support more datetime formats using fuzzy parsing - Parse (and ignore) inline and block comments ([#153](https://codeberg.org/buhtz/hyperorg/issues/153)) - Tooltips on images and figures ([#178](https://codeberg.org/buhtz/hyperorg/issues/178), Michael Olie [@MikeEmpire]) ### Fixed - Don't stop on exceptions from orgparse ([#120](https://codeberg.org/buhtz/hyperorg/issues/120)) - Don't stop on unknown link types ([#70](https://codeberg.org/buhtz/hyperorg/issues/70)) - Sub folder support ([#131](https://codeberg.org/buhtz/hyperorg/issues/131)) - KeyError 'ID' ([#133](https://codeberg.org/buhtz/hyperorg/issues/133), [#95](https://codeberg.org/buhtz/hyperorg/issues/95)) * Rendering of backlinks from subheading ([#150](https://codeberg.org/buhtz/hyperorg/issues/150)) ## [0.1.0] - 2024-03-19 _Initial and first stable release of Hyperorg after two years of development._ ### Added - Convert org files into W3C conform HTML5 and CSS - Keep interlinks between nodes - Backlinks at the end of each node - Add index.html to list all nodes - Support node aliases and filetags - Support attachments and images as external files [@MikeEmpire]: https://codeberg.org/MikeEmpire [0.1.0]: https://codeberg.org/buhtz/hyperorg/releases/tag/v0.1.0 [0.2.0]: https://codeberg.org/buhtz/hyperorg/releases/tag/v0.2.0 hyperorg/CONTRIBUTING.md000066400000000000000000000112251522421240700151460ustar00rootroot00000000000000 # How to contribute to _Hyperog_ 😊 **Thanks for taking the time to contribute!** The maintenance team will welcome all types of contributions. No contribution will be rejected just because it doesn't fit to our quality standards, guidelines or rules. We are willing to guide and mentorship new contributors through the process. September 2025 ## Table of Contents - [How to submit changes and report bugs?](#how-to-submit-changes-and-report-bugs) - [Setup environment, installing and testing](#setup-environment-installing-and-testing) - [Recommendations & Best practices](#recommendations-best-practices) - [Branching model](#branching-model) - [Development documentation](#development-documentation) - [Licensing of contributed material](#licensing-of-contributed-material) # How to submit changes and report bugs? These external resources explain how to accomplishing various tasks through contribution: - [Opening an issue or bug report](https://opensource.guide/how-to-contribute/#opening-an-issue) - [Opening a pull request](https://opensource.guide/how-to-contribute/#opening-a-pull-request) # Setup environment, installing and testing Fork the repository and clone it to your local machine. Please **always make a new branch** when preparing a _Pull Request_ ("PR") and **baseline it on** the repositories default branch `latest`. $ git clone git@codeberg.org:YOUR_USERNAME/hyperorg.git $ cd hyperorg $ git checkout --branch mynewfeature Create a virtual environment, activate it and install _Hyperorg_ (dependencies should be handled automatically by `pip`): $ python3 -m venv .venv (.venv) $ source venv/bin/activate (.venv) $ python3 -m pip install --editable .[develop] Run the test suite using `unittest` or `pytest` with one of the two following commands: (.venv) $ python3 -m unittest (.venv) $ pytest Create and add your modifications and push it to your remote repository: (.venv) $ edit myfancynewfeature.py (.venv) $ git add myfancynewfeature.py (.venv) $ git commit -am 'feat: fancy colors' (.venv) $ git push Go to your repository at [Codeberg.org](https://codeberg.org) and open a _Pull Request_. # Branching model This project utilizes the [OneFlow](https://www.endoflineblog.com/oneflow-a-git-branching-model-and-workflow) branching model, initially outlined by [Adam Ruka](https://github.com/skinny85). Its key aspects and rules are straightforward: there exists only one branch (named `latest` in this repository), and stable releases are denoted by annotated tags using the version number (e.g. `v0.1.1`). # Recommendations & Best practices Please take the following best practices into account if possible (to reduce the work load of the maintainers and to increase the chance that your pull request is accepted): - Follow [PEP 8](https://peps.python.org/pep-0008/) as a minimal Style Guide for Python Code. - Follow [Google Style Guide](https://sphinxcontrib-napoleon.readthedocs.org/en/latest/example_google.html) for docstrings. See also [this example in PyDoctor's documentation](https://pydoctor.readthedocs.io/en/latest/docformat/google_demo/index.html). - Using automatic formatters like `black` is not recommended. Please **mention the use** of them when opening a pull request. - Run the test suite before you open a Pull Request. - Try to create new tests if appropriate. Use Python's regular `unittest` instead of `pytest`. If you know the difference please try follow the _Classical (aka Detroit) school_ instead of _London (aka mockist) school_. # Development documentation - Details are described in the docstrings. Please open an bug report if you miss something. - Concecpt, code structure and design decisions described in `examples/org/20250912150340-hyperorg_design_concept.org`. # Licensing of contributed material Please keep in mind that code, documentation and other material submitted to the project are considered licensed under the same terms (see [LICENSES](LICENSES)) as the rest of the work. The project does use the specifications from [REUSE Software](https://reuse.software) and [SPDX](https://spdx.github.io/spdx-spec) to store license and copyright information. See the projects [REUSE compliance status](https://api.reuse.software/info/codeberg.org/buhtz/hyperorg). June 2026 hyperorg/LICENSES/000077500000000000000000000000001522421240700141215ustar00rootroot00000000000000hyperorg/LICENSES/CC0-1.0.txt000066400000000000000000000156101522421240700155260ustar00rootroot00000000000000Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. hyperorg/LICENSES/GPL-3.0-only.txt000066400000000000000000001035561522421240700165730ustar00rootroot00000000000000GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright © 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. “This License” refers to version 3 of the GNU General Public License. “Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. “The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. A “covered work” means either the unmodified Program or a work based on the Program. To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. “Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. “Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . hyperorg/README.md000066400000000000000000000334251522421240700142020ustar00rootroot00000000000000 [![REUSE status](https://api.reuse.software/badge/codeberg.org/buhtz/hyperorg)](https://api.reuse.software/info/codeberg.org/buhtz/hyperorg) [![Mastodon @buhtz@fosstodon.org](badge_mastodon.svg)](https://fosstodon.org/@buhtz) [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![Liberapay buhtz](badge_liberapay.svg)](https://liberapay.com/buhtz) [![Ko-fi buhtz](badge_kofi.svg)](https://ko-fi.com/buhtz) # Hyperorg - An Org to HTML converter _Hyperorg_ converts [Org Mode](https://orgmode.org/) and [Org-roam](https://www.orgroam.com/) files into a navigable [HTML](https://en.wikipedia.org/wiki/HTML/) representation of a [Zettelkasten](https://en.wikipedia.org/wiki/Zettelkasten/), preserving its link structure and backlinks. It is designed to make an Org-roam vault accessible without requiring [Emacs](https://www.gnu.org/software/emacs/) or a web server. The **project** is active. It is in constant development implementing [new features](https://codeberg.org/buhtz/hyperorg/issues?q=&state=open&labels=40047) and fixing [bugs](https://codeberg.org/buhtz/hyperorg/issues?q=&state=open&labels=40046). See the [development roadmap](#development-roadmap) and the [milestones](https://codeberg.org/buhtz/hyperorg/milestones) about what is coming and the [changelog](https://codeberg.org/buhtz/hyperorg/src/branch/latest/CHANGELOG.md) about what has been. Don't hesitate to voice your wishes and opinions by utilizing the [issue section](https://codeberg.org/buhtz/hyperorg/issues) or the Mastodon account [@buhtz@fosstodon.org](https://fosstodon.org/@buhtz). The **Team** currently consists of a single person: the original author and sole maintainer, [buhtz](https://buhtz.codeberg.pages). [Donations](https://codeberg.org/buhtz/about-me#about-christian-buhtz) via Liberapay and Ko-fi support the maintainer personally rather than this specific project, as he is involved in [several open-source projects](https://codeberg.org/buhtz/about-me#projects). Alternative donation methods are available on request. July 2026 ## Table of contents - [Installation](#installation) - [Usage](#usage) - [Feature List](#feature-list) - [Design Goals](#design-goals) - [Comparison to similar tools](#comparison-to-similar-tools) - [Motivation - Why _Hyperorg_ exists](#motivation-why-_hyperorg_-exists) - [Development roadmap](#development-roadmap) - [Contributing](#contributing) - [Example](#example) - [Technologies & Standards](#technologies-standards) # Installation > [!IMPORTANT] > Always prefer the **official repository** of your GNU/Linux distribution. > _Hyperorg_ is [available on Debian > GNU/Linux](https://tracker.debian.org/pkg/hyperorg) and therefore other > Debian-based distros. See [this list](https://repology.org/project/hyperorg) > about availability in other distros. **Regular installation for users**: See the [Releases](https://codeberg.org/buhtz/hyperorg/releases) section or run the following command to install the _latest stable release_ from upstream repository: ``` $ pipx install https://codeberg.org/buhtz/hyperorg/archive/v0.2.0.zip ``` If `pipx` is not available on your system check your GNU/Linux distributions package repository or install it from PyPi (`pip install pipx`). **Editable installation for developers to contribute**: First of all fork/clone/copy the repository to your local machine. ```sh $ git clone https://codeberg.org/buhtz/hyperorg.git $ cd hyperorg ``` Use a virtual environment and the extra `[develop]`. ```sh # Create the environment $ python3 -m venv .venv # Activate the environment $ source .venv/bin/activate # Install $ pip3 install --editable .[develop] ``` When you are finished with your working session, don't forget to deactivate the environment. ```sh $ deactivate ``` # Usage ``` $ hyperorg ~/orgfolder ~/htmlfolder ``` This will take all `*.org` files in the folder `~/orgfolder` and store them converted to HTML in `~/htmlfolder`. Open the `index.html` file in the output folder to see the result. These are all available options: ``` usage: hyperorg [-h] [--hardlinks] [-s] [-v] [-d] [--version] inputdir outputdir positional arguments: inputdir Source directory with org-files. outputdir Destination directory for html-files. optional arguments: -h, --help show this help message and exit --hardlinks Use hardlinks instead of symlinks for images and other attachments in the input directory. -s, --show Open result in default browser. -v, --verbose Give detailed information. -d, --debug Debug output. --version show program's version number and exit ``` # Feature List - Convert all nodes in an Org-roam vault (collection of org files) into HTML. - Preserves links between nodes. - Generates backlinks for each node. - Generates a comprehensive index of all nodes. - Not stopping on malformed nodes, or broken links, but emitting clear warnings. # Design Goals - Functions out of the box, no configuration needed. - Operates as a standalone application, eliminating the requirement for Emacs. - Serverless local use of HTML files. - Robust handling of parsing issues. - Adheres to World Wide Web Consortium (W3C) standards for HTML5 and CSS (``). - No JavaScript or other dynamic web techniques, except when really necessary (e.g. MathJax). # Comparison to similar tools _Hyperorg_ takes an existing Org-roam **link graph**, exports it **out-of-the-box** as a navigable HTML structure and does not require configuration. The Org-roam vault is treated as a graph of nodes, not just a collection of independent files. ID-based links are first-class, meaning that `id:` links are resolved as real graph edges between nodes, not treated as plain text links. Backlinks are generated as part of the regular output. Every page includes incoming references automatically. All of this works without configuration. Similar tools like `org-export`, `org-publish` or _Hugo_, do model files and publishing rules, while _Hyperorg_ models the org-roam graph itself as the direct basis for HTML generation. # Motivation - Why _Hyperorg_ exists The creation of _Hyperorg_ was initially driven by two aspects. First, there was the need to make an Org-roam vault accessible on systems without an Emacs available. Second, there was the experience that there is no known solution available to reliably handle the `id:` links in Org-roam nodes and to meet the requirements of a Zettelkasten scenario. In addition, the project was influcenced by practical constraints regarding the authors personal skill set and a lack of motivation and intent to learn or adapt to extensive configuration systems in other tools. Alternative tools doesn't meet all mentioned design goals and features, not because they are not good but they simply target on different use cases and design goals. # Development roadmap - **Short term** - Primary focus is on improving maintainability and testability of the grown codebase via rearanging and refactoring it. See meta issue [#195](https://codeberg.org/buhtz/hyperorg/issues/195). - **Medium term** - [Next release after `0.2.0`](https://codeberg.org/buhtz/hyperorg/milestone/5750). ([#85](https://codeberg.org/buhtz/hyperorg/issues/i85)). - Implement feature: Table of content - Several more features. - Improve visual appearance and sexyness via color scheming and more. See [CSS-related issues](https://codeberg.org/buhtz/hyperorg/issues?labels=180551) and [#66](https://codeberg.org/buhtz/hyperorg/issues/66). - **Long term** - Integrate the necessary features of `orgparse` and remove that dependency. - Separate the parser component into its own project to make it accessible to others. # Contributing Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details about how to contribute to the project. Details about the concecpt, code structure and design decisions can be found in [`examples/org/20250912150340-hyperorg_design_concept.org`](https://codeberg.org/buhtz/hyperorg/raw/example/org/20250912150340-hyperorg_design_concept.org). The following communities have supported this project with contributions to discussions, tips, other valuable contributions and also inspirations. - The communities around [Emacs](https://www.gnu.org/software/emacs/), [Org-roam](https://www.orgroam.com/), [Org-mode](https://orgmode.org/) and [ox-hugo](https://ox-hugo.scripter.co/). - [Regex101](https://regex101.com) - [StackOverflow](https://stackoverflow.com) - [debianforum.de](https://debianforum.de) - And others. # Example On the left side of this screenshot, an _Emac's_ window displaying an _Org_ buffer can be seen, while on the right side, the same content is prestend in _HTML_ format. ![Example Screenshot](misc/screenshot01.png) Here you can observe the identical content presented in both raw _Org_ and _HTML_ formats.
Org HTML
```Markdown :PROPERTIES: :ID: 13ec1e89-abc9-4d43-a4a4-3005a1c9dfc7 :END: #+title: Foo #+date: [2022-03-09 Mi 09:30] * Introduction This node is named "foo" and should link to "bar". This is the link: [[id:e4bd446b-216b-4e34-8d10-428b6fa5e257][bar]] * H1 Heading ** H2 Heading Governments of the Industrial World, you weary giants of flesh and steel, I come from Cyberspace, the new home of Mind. On behalf of the future, I ask you of the past to leave us alone. - You are not welcome among us. - You have no sovereignty where we gather. We have no elected government, nor are we likely to have one, so I address you with no greater authority than that with which liberty itself always speaks. 1. I declare the global social space we are building to be naturally independent 2. of the tyrannies you seek to impose on us. Source: [[https://www.eff.org/de/cyberspace-independence]] ** A https link [[https://codeberg.org/buhtz/hyperorg][The "hyperorg" repository]] * About backlinks See the autogenerated backlinks in the next section. The node "bar" does link to this node "foo". ``` ```html Foo

Foo

date: [2022-03-09 Mi 09:30]

Introduction

This node is named "foo" and should link to "bar". This is the link: bar

H1 Heading

H2 Heading

Governments of the Industrial World, you weary giants of flesh and steel, I come from Cyberspace, the new home of Mind. On behalf of the future, I ask you of the past to leave us alone.

  • You are not welcome among us.
  • You have no sovereignty where we gather.

We have no elected government, nor are we likely to have one, so I address you with no greater authority than that with which liberty itself always speaks.

  1. I declare the global social space we are building to be naturally independent
  2. of the tyrannies you seek to impose on us.

Source: https://www.eff.org/de/cyberspace-independence

A https link

The "hyperorg" repository

About backlinks

See the autogenerated backlinks in the next section. The node "bar" does link to this node "foo".


Backlinks

Generated with hyperorg 0.1.0 on Di 19 Mär 2024 09:03:51
```
# Technologies & Standards - [Python 3](https://python.org) - [`orgparse`](https://orgparse.readthedocs.io/en/latest/) - [W3C](https://www.w3.org/) conform [HTML5](https://wikipedia.org/wiki/HTML5) & [CSS](https://wikipedia.org/wiki/CSS) - [Semantic Versioning](https://semver.org), [Common Changelog](https://common-changelog.org) and [Conventional Commits](https://www.conventionalcommits.org) - [REUSE Software](https://reuse.software) and [SPDX](https://spdx.github.io/spdx-spec) ([ISO/IEC 5962:2021](https://www.iso.org/obp/ui/en/#iso:std:iso-iec:5962)) specifications hyperorg/badge_kofi.svg000066400000000000000000000064521522421240700155160ustar00rootroot00000000000000 Kofi: buhtzKofibuhtz hyperorg/badge_liberapay.svg000066400000000000000000000063721522421240700165370ustar00rootroot00000000000000 Liberapay: buhtzLiberapaybuhtz hyperorg/badge_mastodon.svg000066400000000000000000000062041522421240700164050ustar00rootroot00000000000000 mastodon: @buhtz@fosstodonmastodon@buhtz@fosstodonhyperorg/example/000077500000000000000000000000001522421240700143475ustar00rootroot00000000000000hyperorg/example/README.md000066400000000000000000000010741522421240700156300ustar00rootroot00000000000000 # Example The folder `org` contain Org Roam nodes and pictures as attachments. To generate HTML of it use the following command. $ hyperorg org html The result can be found in the folder `html`. It is recommended to start with opening the file `index.html` then. hyperorg/example/org/000077500000000000000000000000001522421240700151365ustar00rootroot00000000000000hyperorg/example/org/20240611213720-sol.org000066400000000000000000000024531522421240700200240ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: 55c55f73-9f29-49ad-a7b9-58a1b6757165 :END: #+title: Solar System #+date: [2024-06-11 21:37] * Introduction The age of the system is about 4.6 billion years (see [[https://en.wikipedia.org/wiki/Solar_System][Wikipedia]]). The [[id:18bce187-422f-47a5-b0ff-f56f63595749][Sun]], in the center of the system, is the largest object. There are [[id:9cdd8a15-9da1-4752-a59d-9a434cc33885][eight planets]] and [[roam:dwarf planets]] and other objects in that system. * The planets of the Solar System :PROPERTIES: :ID: 9cdd8a15-9da1-4752-a59d-9a434cc33885 :ROAM_ALIASES: "Solar System's Planets" :END: The following planets ordered ascending by their range to the Sun. 1. [[id:775e781b-bad5-4b0f-ac13-10f168ef789b][Mercury]] 2. [[id:e93709fc-3bb5-46d8-a46e-481dae283641][Venus]] 3. [[id:0a664070-e013-452b-97e3-154aa2fb7069][Earth]] 4. [[id:4a4580ef-d5f9-48e5-9349-8df7ba7b9fca][Mars]] 5. Jupiter 6. [[id:b269a1be-6451-4b7c-abd7-615aa326f9e8][Saturn]] 7. Uranus 8. Neptune hyperorg/example/org/20240611214854-earth.org000066400000000000000000000030361522421240700203410ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: 0a664070-e013-452b-97e3-154aa2fb7069 :END: #+title: Earth #+date: [2024-06-11 21:48] You might know the Earth, aren't you? * Some pictures ** ASCII-Art code block #+begin_src text _____ ,-:` \;',`'-, .'-;_,; ':-;_,'. /; '/ , _`.-\ | '`. (` /` ` \`| |:. `\`-. \_ / | | ( `, .`\ ;'| \ | .' `-'/ `. ;/ .' `'-._____. #+end_src ** Picture *** In 240 x 240 pixels #+ATTR_HTML: :title Original size [[The_Blue_Marble_(remastered).jpg]] *** Smaller [[The_Blue_Marble_(remastered)_small.jpg]] *** Red border #+ATTR_HTML: :style border: 10px solid red :title With red border [[The_Blue_Marble_(remastered)_small.jpg]] *** Inline picture The [[The_Blue_Marble_(remastered)_small.jpg]] is blue. Click on [[The_Blue_Marble_(remastered).jpg][file:The_Blue_Marble_(remastered)_small.jpg]] for the bigger version. *** Figure with caption #+CAPTION: The /Blue Marbel/. Taken 1972 on Apollo 17 mission. [[https://commons.wikimedia.org/wiki/File:The_Blue_Marble_(remastered).jpg][Original at Wikimedia]]. #+ATTR_HTML: :title NASA/Apollo 17 crew; taken by either Harrison Schmitt or Ron Evans, Public domain, via Wikimedia Commons [[The_Blue_Marble_(remastered).jpg]] hyperorg/example/org/20240611215225-sun.org000066400000000000000000000022241522421240700200320ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: 18bce187-422f-47a5-b0ff-f56f63595749 :END: #+title: Sun #+date: [2024-06-11 21:52] | **Property** | **Value** | |--------------------------|---------------------------------| | Diameter | approx. 1.39 million km | | Mass | approx. 1.989 × 10^30 kg | | Age | approx. 4.6 billion years | | Surface Temperature | approx. 5,500 °C | | Core Temperature | approx. 15 million °C | | Luminosity | approx. 3.828 × 10^26 watts | | Composition | approx. 74% hydrogen, 24% helium, 2% heavier elements | | Distance to Earth | approx. 149.6 million km (1 Astronomical Unit, AU) | | Spectral Class | G2V (yellow dwarf) | hyperorg/example/org/20240611215502-mercury.org000066400000000000000000000016301522421240700207110ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: 775e781b-bad5-4b0f-ac13-10f168ef789b :END: #+title: Mercury #+date: [2024-06-11 21:55] - First planet in the [[id:55c55f73-9f29-49ad-a7b9-58a1b6757165][Solar System]] - Smallest in the Solar System More facts (using description list): - Diameter :: approx. 4,880 km - Mass :: approx. 3.301 × 10^23 kg (0.055 Earth masses) - Age :: approx. 4.5 billion years - Surface Temperature :: -173 °C to 427 °C - Orbital Period :: approx. 88 days - Rotational Period :: approx. 59 days - Distance to the Sun :: approx. 57.9 million km (0.39 Astronomical Units) hyperorg/example/org/20240611220130-venus.org000066400000000000000000000015421522421240700203560ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: e93709fc-3bb5-46d8-a46e-481dae283641 :END: #+title: Venus #+date: [2024-06-11 22:01] Facts about the Venus (using enumerated list): 1. /Diameter/: approx. 12,104 km 2. /Mass/: approx. 4.867 × 10^24 kg (0.815 Earth masses) 3. /Age/: approx. 4.5 billion years 4. /Surface temperature/: approx. 462 °C 5. /Orbital period around the Sun/: approx. 225 days 6. /Rotation period/: approx. 243 days 7. /Distance to the Sun/: approx. 108.2 million km (0.72 [[id:f38262d6-3132-43cc-8ad6-d8ce0e8ea70c][Astronomical Units]]) hyperorg/example/org/20240611220535-mars.org000066400000000000000000000017141522421240700201720ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: 4a4580ef-d5f9-48e5-9349-8df7ba7b9fca :END: #+title: Mars #+date: [2024-06-11 22:05] The Mars is the 4th planet in [[id:55c55f73-9f29-49ad-a7b9-58a1b6757165][our system]]. Facts about the /Mars/ (using an unordered list): - /Diameter/: approx. =6,779 km= - /Mass/: approx. 6.39 × 10^23 kg (0.107 Earth masses) - /Age/: approx. 4.6 billion years - /Surface temperature/: ~-87 °C~ to ~-5 °C~ - /Orbital period around the Sun/: approx. 687 days - /Rotation period/: approx. 24.6 hours - /Distance to the Sun/: approx. 227.9 million km (1.52 Astronomical Units) - /Number of Moons/: 2 (Phobos and Deimos) hyperorg/example/org/20240612105046-saturn.org000066400000000000000000000051051522421240700205420ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: b269a1be-6451-4b7c-abd7-615aa326f9e8 :END: #+title: Saturn #+date: [2024-06-12 10:50] The [[id:9cdd8a15-9da1-4752-a59d-9a434cc33885][planet]] Saturn is the 6th in the [[id:55c55f73-9f29-49ad-a7b9-58a1b6757165][solar system]]. ** Links about Saturn This is a link to the related Wikipedia article: [[https://en.wikipedia.org/wiki/Saturn]] This is the same link using a description label: [[https://en.wikipedia.org/wiki/Saturn][Saturn (Wikipedia)]] Here is a /dead/ link with a description label to an OrgRoam node that does not exist anymore: [[id:f38262d6-3132-43cc-8ad6-d8ce0e8ea70c][Astronomical Units]] Here is a /dead/ link to a OrgRoam node that does not exist anymore: [[id:f38262d6-3132-43cc-8ad6-d8ce0e8ea70c]] Supported or recognized *link types* are: 1. OrgRoam v2 ID Links - As code: =[[id:1234]]= - Example: [[id:4a4580ef-d5f9-48e5-9349-8df7ba7b9fca]] 2. ROAM Links - As code: =[[roam:OuterSpace]]= - Example: [[roam:OuterSpace]] - That links are not resolved. 3. Hypertext Transfer Protocol Secure (HTTPS) - As code: =[[https://emacs.org]]= - Example: [[https://emacs.org]] 4. Hypertext Transfer Protocol (HTTP) - As code: =[[http://emacs.org]]= - Example: [[http://emacs.org]] - Treated as insecure and not rendered as valid link because of that. 5. Image File - As code: =[[file:The_Blue_Marble_(remastered)_small.jpg]]= - Example: [[file:The_Blue_Marble_(remastered)_small.jpg]] 6. Other Files - As code: =[[file:foobar.zip]]= - Example: [[file:foobar.zip]] 7. Mailto - As code: =[[mailto:c.buhtz@posteo.jp]]= - Example: [[mailto:c.buhtz@posteo.jp]] 8. Digital Object Identifier (DOI) - As code: =[[doi:10.1007/s11214-020-00751-1]]= - Example: [[doi:10.1007/s11214-020-00751-1]] 9. Unknown link types - As code: =[[foobar:nirvana]]= - Example: [[foobar:nirvana]] - Hyperorg will also warn you on terminal about links of this type. ** Math about Saturn *** Inline Saturn’s surface gravity can be expressed inline as $g = \frac{GM}{R^2}$. Or using the alternative syntax: \(v = \sqrt{\frac{GM}{R}}\) *** "Displayed" as blocks Saturn’s surface gravity: \[g = \frac{GM}{R^2}\]. \[g = \frac{GM}{R^2}\] \[ g = \frac{GM}{R^2} \] Or using the alternative syntax: $$v = \sqrt{\frac{GM}{R}}$$ hyperorg/example/org/20250912150340-hyperorg_design_concept.org000066400000000000000000000110231522421240700241260ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to :PROPERTIES: :ID: 8cc00b3a-255e-456f-9e12-95c21de00b36 :END: #+title: Hyperorg Design Concept #+date: [2025-09-12 15:03] * Introduction This documents describe the design concept behind [[id:d0a0d7c9-8aa2-4377-b20b-ab54c93b49d6][Hyperorg]]. It contains technical details and reasonate some design decisions. These are not hard rules and there is always the opportunity to modify the concept for good reasons. Hyperorg... - ... tries to mimik the behavior of ~org-roam-export-to-html~. - ... do not copy the exact HTML output of ~org-roam-export-to-html~. - ... should not break when encountering unsupported or unknown org-syntax. * Basics ** Org document An org document consists of a header and a body. The header with its =PROPERTIES= drawer is handled by the extern =orgparse= package. The body is handled by Hyperorg itself. ** Elements and Runs From top to bottom the body of an org file contains /elements/ (=elements.py=). Imagine it like a stack on a vertical axis. Each /element/ can contain several /runs/ (~class Run~). They are organized side by side in an horizontal axis. Everything that needs to have its own line and generates a /newline/ is an /element/. See file =textruns.py= and =elements.py= for insights. Technically each /element/ class is also a /run/ (direct or indirect derived from ~class Run~). The distinguishing between /run/ and /element/ happens on a concept level but not in the itself, despite it affects the organization of code and files. A paragraph (~class Paragraph~) for example is an /element/. The text contained in a /paragraph/ is organized into /runs/. The most simplest run is just a string (~class str~). In case the text has bold markup it will be represented by a /markup/ run (~class Markup~). *** Example #+begin_src org This is a raw paragraph with two lines. The second paragraph contain *bold text* as an example. #+end_src The first paragraph will result in an ~class Paragraph~ instance containing the text =This is a raw paragraph with two lines.= as a simple string. Note that there is no newline character. The second is a ~Paragraph~ with a list of three /runs/: The string "The second paragraph contain ". As second a /markup/ run (~class Markup~) of type =IMPORTANT= (result in *bold* rendering). As third, again a string /run/ with "as an example.". ** Nested Runs An example for nested runs is a link with a label. #+begin_src org very *cool and /free/* #+end_src This is ~Paragraph~ with two elements: The ~str~ "very " and a ~Markup~ (type =IMPORTANT=) with "cool and free". But that ~Markup~ contain two entries itself: The string "cool and " and another ~Markup~ (type =EMPHASIZED=) holding the string "free". * The code base - /Runs/ are defined in =textruns.py=. - /Elements/ are defined in =elements.py=. - Parsing and creating /elements/ happens in ~parser.py::OrgContentParser.result()~. - Parsing and creating /runs/ happens in =textruns.py=. ** The parsing process This section describe the parsing process and its steps. Not all details and cases are covered. The goal is just to give an idea about the parsing workflow. 1. The raw org-file is read by `orgparser` (an external package) first. That component does parse the meta data (e.g. `PROPERTIES` drawer) of the org file and additionally provide the org-files body as a simple list of lines. Location in code is ~reader.py::Reader.read_org_files()~. Org files (and org sections with their own =ORGID=) are described as /nodes/. So final result of the parsing process will be a list of ~Node~ (see file =content.py=) instances. The ~Reader~ will create a ~Node~ instance for each org file. 2. The ~Node~ instance itself will handle the parsing of the content in its body (see ~content.py::Node.parse_lines()~). It calls ~parser.py::OrgContentParser.result()~ for help in this process. 3. The ~OrgContentParser~ iterates over each line of the body and create /elements/ (e.g. ~Paragraph~, ~Figure~, ...) of it. But the inner content of an /element/ is not parsed in this location. 4. Parsing of the inner content of an /element/, like a ~Pargraph~ with its markup or inline images, is handled by the /element/ itself. Because an /element/ consists of several /runs/ that parsing is handled in =textruns.py=. hyperorg/example/org/The_Blue_Marble_(remastered).jpg000066400000000000000000000465021522421240700231540ustar00rootroot00000000000000C     C  " E!1"A2Qaq#BRb3r$&456Ss0!1"AQ2a3Bq# ?FΈLhѣD!F4B5:غb5Fl P #@N; @4c"yfl2&WO7?UG0kyJcST/OkpF&s&uTI7#̺ĿP3j3{ ׉#4]Gz%k:鋷èi#[:jo|E>-*\6׹{m ~96'~)gNΙtHKEP{G[J1W 숿>KZCH/ӎu6PlvqehuQTKC}64s EN1jW%tP ^s7婊Ya/ R)e)l^_o)[NfEi3G"CX!jA5D^V 9!*#7) N R*c4*s Eo'wPl?6+x2APW}4.1O%:y{V#QV8+&IOЍ^3Qyb y r?_OLƖ9P=Ì>}lv\^pvnZuʔS91O!Χ;v + $gQi慁O PսDFM$le];0xҊ: ueBQ!*a#{@ՠ }ᇫLKQc ? @hII#xxFIeaV[o%Rդ%#y\Tu\u7lzb](%B786~ҟV}p^X#x7𚹕n&m\E}؝WztjKD4hѨICF! 4h4hѢthhѮ;`N{mڭ "U<HT3 ? UOi8YDv)2 <*qT3qꔨT0FiD0UW2HL!/''ߓ`*BWF`vӨcd>bRTdXA)N{c7 xX(閝]v{Jj.t "p钿hx;U519İtͭ1?-0>}nc r*G2yzqPھs0f3.$}D0]XYWVb1Ii{,}Fkڣ!AOTNw2F䚮P*Tq'ܝ>oP,Z:࠸$09pAI rTH$GSyRI&1pwV#gLCf, 3wOS`Z* 4qQ=@綬S[n>_k O]4rKtx8Uċ$"qIr]Lϱ!IX^(Ɩ)IeFڸ=כr5E4}D4}X=8 SW)1Dvfwܰ:j)4PH{l#kYgFKk:)cfj(>;ȋs۬[j[jbڹѣF ΍4B5Yx[7k o2%BRlS;L u4Ccr,WA]NAO!6j9"e83=`} a"c5i-R!I~{MY(,KMK AQ騵OQvہZF W[{IMc 3$馷bI= pΣ.n\rz\clY$줅y'di=}A2''oAn !\pO~NTk1nWՅiDT {O$bfq%J;elzbDv7,d N@gZDP<,F@pF}bfcZqI63v]LG5E"J O'{on4z k`˰rH#S+_EW璜ja;"#rA 2@9su%-]fqoi7o ” pyn3>_w2DqAJdجU[n}>fhj.8֦5:?XYn 9J.4UUU4fh+;p1qh$Q 4CT9pHjgPEIH HBXFf,Nk´u JJ#9UPcTOl;!{&*0<1 _s.sMɞKOOEyT@|й%'9Foq 31ޒ^#XGJm #w'X^ҕdA"<Zg H L-GY;ۺhD5 iiXIƒ2O#辰WZN!H*K8%OϷm=MV)l mV fg1<1݌|4u=%*ULB3!T@#P?~XgʪDPc`7CUdz;@oQ?_|jZzZ{ Dz*h$r3tȽSUSE[GG$O'RƧ S <>L5YY' >X7pFքZ RԳHb(@h},Tjo0Z$IS~TQtUf[lY_0}=c'wK_E jāț'w:E-]HcjsYlSgtO}cێau'SXaI3Uǔn9i*n1+wn$ m Ftt\M5h|<`v׮0YgԟzJ^:{njf>e3xL1RmdCFQth }?hmUyrpi;>wu8|?ODnh## G+&A(]YIڃ;cI8CP miI"tB\%}Ϲ˅)Q-R wN3 >rY&5~iJ8l(wMm *ieevfY`H*0W=t49KĘ#҆Y@ݖ zT`GO2W+Ȭ9.P` 69:} ^̯JHQ`X{ X}ecV?I M%!# dzAaU:q2d)5ʓ$H&W  rra Mh%[뙣X`B>H+j*픔$+RV^Wr"n"rbI鮫k[U] JHPUS"*2o*p coe#09c~Z**৳:żv7 adHU9po9c9鞌JVUdKT Q S !m$x<9/LtuSS]W$~C_\l.MkGbZQI %]U Ļ<;}GAl0 KrQGnDZ JN6edLe Jw:oHMw˭tK D4%= y2,XnKM QݒԒC5U5Dt| `{y::[)Ԕ/43FSȀyj1k m;O5J9yj'yH$ʞ[c#:_*nKV5x;tʥwn[./jӊR߮(bZ:hU*2ʡI!6o S(kTF3*aP$n̈́ %ӕ|eH7ЕVխiMN X%T'xQe###V]wYlk!ҴoGSCcWʍ>qpd~jME]KsQIT*H`]Fr5 x}QwOHM jb+ 2g/Žu- ixԵd#pKMKYL5}TQ<: 7$`/gN}1~xr3Z`|y3Ti*gvEFE5P$sFeːgIbڪO_m9Tٱpۉ}{r6ޙ;m•e D$7 YCzGb;ꠥhz5Ke2YZ. .b彁VAP6G]oVh$5A;z6BaQ-@3^8J쒊2MEWϱjMEiZ7YR )?v1?4XVccmFxv'YZ2p!B:54byщ؍e.2t;FG=_O2[jޚ 7TK25=N}S_SEK'<$ *7< :ꋣmUV$ [M8X6*S 7 y1ٗh;[(*p7P6[$CIΫ|[(jD XA29#iPߺN ,ez*IJKSF)2bʏ[UϚްLD)&pa,9jHon1HK,ǍlOߟlj5qZ \מYA^wY*ׅ/i!)$``G:f l_ϖy'jJ3H]x7lZH sQO?ÁP0.\}Y8< gVE{ձhn0:K2K$jO0='ƚv&EW/v_&/ Jl` N{'M)`ѧ #kӞ .WfG-]? ȍΉG +I2r lK]n _ef9 UQR$0V4+̢kdqTѥ a pJTmpyIҊ:یBgTKC2 L;W`! sԚl4G%'w]-G!i#T;?hNILm&6 xo-+p}coX?z. c:uw~,TfiR}c]vm5;2ߜW׽}Jeli 888g9< ,9ѵZXBF ;bSN{ic4g/ '9Ƥft^OSQ?1I;1rʶXswPNDwWPq8mNg(3W`MjZ[oM\|̲dYCHI>+]l 7RuTiT0CebyE睬@:箹joFe}ѝ#(F>q]Ŏ9^B3zys#4e~cI9~[ej> UPc;ʟQ@R՝{*kf¨X6IYQ_*=J|yATERa gՌӧPޣ7ژ$VaRx`''ؓLznY=U$ Crv!Om(="cxK:{?8OmӨzǨ:֚隮 RrY,C ?Sގ[0J 4Oݽc)8;Kv+@ЍF|ҙV5s4A=q5H4"Ǚ4++TH`>>,XjD+mŖI#ҢK1EA!#УQ]uղ^'X%I/ .8;@ O#L+ܓN 8き4^%B_{rr0[12-9:"WwHX yQ?!XեMp\2:۩e2D?ͦn0O6bnF4w%x(i*$$+,ʫ'&$@9<6#¾y~:TInL(.s8p?9Tgv$e}޿{ORKW+GG^#=|aRvs ]$StJRh=l2=};E/S5%[N'A#evWI5Uurd53_ q)]K`ry5D Ia@0 ӽdUENL/ޤQYsvc'K|5t Yu1`H͐[#!s}I3X+`O)O) #1 NOazjY&D+*U@qr0-WPAoU DY$6K~< !fB?y FN>5naXzIGrgJdX)~JYe]2ܲ${sV}:QQBO*d98I Ɗ+E}\ 6YN8`[88{+617w[oPpi8Ɍ-$s%ֆqB0i6Þ !\8|URLqmI@ų?oܬ4{ Īz}Ϋ5X9ɜ3}Z š>*UmMxN)쪥U |DSc] @KJKb;)}syR" qdi/UӨ'QD;q/[/RԦ`(% Y̫'2s0G:wYybDEs{sMtvJzG$H(;;'8uO8i>:7 )CQ, N6}rs1nΖRp+R)KDCnmm0N၌25 -۬bP`i~:|#eBHie2yaX,PfE,ތ8 :kGLg?[g2U+A[OG2M[lnpHTN? º [[aޞ^s8%\i'KH1tN2dUt$#1ymtsWT=IRqL'GpÐ0Ý`h֞$k}z垮CR6j#~G3AjR^[$-$YAقyۜ~b W$QI[rZ ULh)d*j!KJlIʒFr7}꞊I[Aδ]0x5Ӯw ImPwaQ:gA>8jUSs1mŦadW18(r?fUicY9zX=QfӰP2;OqXkjM ch`*A<O}!:-*(Ro|̨}ku u&Zz*TIP8`YP$xЙ2]rtdH$DQ馊KLU? <Մ1 gkT:WC$V-$<~]*aXcΕ==SvHTdƥ?=Kߩ5Oo*cPQʎ_c3zyDјI'G9[wOA U%L{69AW*b1 vMew>eusJ2AԐ|&r695O5kZ,Q#rKlRdd=;}awΛdVl+n4v#hk#. $I%Onqex9cGωzi"jYrP*q9:tN.RݥRFel1V۷v wF1،'w=})V4S{czpHy)MU@9yFL)U⦩5(dܕC#:0jJr$,St*AV(Q,[; C,10%1ۑI17rO׸zx J?~a`Lj,W'zrKYH̫gR "ti<(nH] >X5!nzQL'ZQhYՔ`=RtAEYKLSM# С2ߐ ƬE?LOiZh#QQ$9WCzgw s?RKTjMˁM2 Ll8#^pm0V')ݻ᭑M'3˔$ naܒr@]8:yd6xaVUO04jX'9W C-ajL0,>Hv2N#}kUY]F*EjO6NfL`ijKJL @NZ*`*I!44Ni .blNGg!Gnso i2NcB1+(rNr8QKWS_1j t̒h-fBP#&pFuh]zKM46B&*pv}Qajk *~,v^4t=yY9܍E}/dnMO,Wĩ,lxNIϫgROn<&J{)0LjX8cAƠskeKI&tM 9~l%=5Q!3(O0QDሕ0!Xc'"ѶVzSPKb.yؖGe猓]`>55Sy7RP#A_ 1Wl$26A vWBܯN3,(͋op{꯽YS=/jXV|FBV;},k :Q[[ަQT$v!,pp9,;݂ \"I )> Rq-9om޹JI&*8J_)f8 eƚ/v<A=$B"#"19_1]-ΓHZ!4r^*Sm߶:)("zȽRi0BL3ƢcCYlZ{ afC#!q>uUxEYyZſj+SôQ(9?N3pGU7eW -;-Ul&w]Ԓޕe88 >>UZ JV)D8olӜ?V"SnB9I *|%':Mjhj'x!)YG ,{GuBԛqnXU}3199?9\h.ЙcrS{IqHd飷IaO# 6婛3MXvy[@lI ?ӝ^np|봩9`psv4"lIؐd*uY:Ӽ+5csuN8[d#TOYruһwWZ`TN9@J3FBJ}9zvxPbWw]|lqH5iX--Mlz$wGz&`ݐq9$5ǟSѝiҵjhnqr .Gدaxsv-(J!J$ x# 9ߐuX!߯d-L|^C*c4K*t/<:z|iMtR0$z@8<(Yk)QE8.zRB)ӞFtC5o4 VRV: S>Oca3U|C}vٰFT$B7ƪo{UE}ŮDpQAq8fA`PɔYM& J w:zXY1e`X c9Մx_Ȥk2tQ h8̄0*:A8v!?I$|rUV[-$ \qYKVZJd=koP'~F5n6ު{tUkӏ2cE%s$q1i?[;|,iM7E(yA{8aLWKƱ9\*3BpFyj]!$Jyȱ%xmN|CSԶ;yw: ێnNkRu%.4[mI$a ed;Ĭz{:v[IԙZ)cv T =X29/+S0ʁNd$0<󧙮6i+M+TxdV @b 4):;:ycOE@MO+d ȓ915k=ȢqF2>q7πwTı[kMLQyr.^jagn,l=Rnl4r ]8\.']+zVm+g鵨GaR|)\p`uRT@\*c~dX܄@'g#'gF*—u]"5OV&0w%N>sRK*GF, 6#l@rzNЛQaxnj1Ψ[vX7tioPҤ*9z[04 YGXKTe(~P??zem:[ ƧʀQMMʆo.F7;~_g[Qr9eRԳwY4kMgXѢm7\}3Ф 2@RUǹď*m}(ţm4" i*?ë4r>GA|r~N:b.:Ivwjfbg(}HIݭQgU,S-u5qӤՊa,p ' g G_SPehi|p4JdTx>67!'~[E=N^(jؤ5d!9tVc8OF(D29&y%$Q/O=ۄWa:qJ H͵6"ۀ [$ z*ih%TTID pW8 p!M_HVg/, DFA-WZt; J6K7nx *$)OXP RNN{Zo@Uj2=4sh8{c5w [kha25H0JF[<XoCC;R$8ٲ|ț #c8o?,Ft"d2DmFvkLiJ[jl> wHI r*G馞 $D-8P0[%21G6jG$6g~5@#uBKDlc'\WVWdndCJf_+>o:KQ}%ZFk:Ƴ~4h H:#n]6ޢ:wl~RT#ue%OuIS8@#}E.WK/v0Vۜ.6^M7Щ$g`itm=tN*V(+r=g믟x/h %GI]_qˑ)Ԗ)+GyU1IaXYPdWǴ5]e髧@c㩖Qv3yd+)l9VںX$l4ovmTKr)igo.x,1Jz| e l=%=FMœ0b?0E l,RKѽGQkrX͠΍cQugD!F}@TxkWj?9"䶻UxYpCkmK `ϭwERK :y7yYÑtauW,LRzH,rL2HA8c_?<ᔱ+[JI.h|ͲSCn>9ֲZ)+L׬oɍ}+zru{ݢA~|~n#y檆)^g2J.F2~sswe.};-jH BW&s,b(U$e sMR)"OP˄al(ITqq7):6I7pۼ@#㾣%y 4yH8Ӟ Ù }"{lu #Iȡ I+vΖP!E-*c?QQm##?N5'jRtu3a 3$ǧ=t8Sg3d!PH$0#Xmƶ ",J(lppsβ;LcJA̍s (' NWSF#CJ9y 2 '}QEyz[T=DX*6jK|XO7w<0cy:K\}8NK/.O }ΒoMPN)wyorgN 3D@}wcFl`CF*25cD&thѢu! 5  .%sXk9IS$Cu?Fj=RV*r'`ķOѡ%^JvMjdĞZ/~r 段Bմ )H\RN}C|Ӣ$-{kedhŁ"<:gۦ՞{tUo<9>[꿣9>*tYឤK Uy3ɵ5`9d).Pd8:%\${nR~ok0 $RALl{L a`r]qkaոhQ}nI}#ru ̐V&vw:o8*{C6f[֊XMX:mj/$h`?8XFg@:FLk.s5F㝛VjD7|-4okƎ2)*Ų0Q7>w7U檿To,/NN%etˤF&H#?sѣY,eP4hdthѢѣFLhѣD!FѣFCF! 4htgh *=.=|I k9PI{*79N[^Ś%Ph_^]̀˺yJY˪F}d|%pQ\CUmϽ=2GxwVWBC91hOQk~:*p&fyi&vG9fcOܝxαF! 4h5cYhѣD!Fhyperorg/example/org/The_Blue_Marble_(remastered).jpg.license000066400000000000000000000012361522421240700245700ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 1972 Harrison Hagan Schmitt, Public domain, via Wikimedia Commons # # SPDX-License-Identifier: CC0-1.0 # # SPDX-Creator: NASA/Apollo 17 crew; taken by either Harrison Schmitt or Ron Evans # SPDX-Created: 1972-12-17 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to # # File URL: # Original URL: hyperorg/example/org/The_Blue_Marble_(remastered)_small.jpg000066400000000000000000000101131522421240700243310ustar00rootroot00000000000000JFIF"ExifMM*C      C  HH" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?4M}sXk_P\j~rauy`7ح7b݃$*(ҝ79rM_/jO`|?Iy2Dt&e g=3W;V =:wbyK$g T jHzc5>,U22$ s(2wS (z>ӿ/jZ|k {P'j5kY؞165m5(C5g2OxǿJ&Tƕ5lj!5 K \K0JA"p&4rNty9rzClXx `ap}b5 &kiA1Z!9y1ϗar:*1~| yt-K% mF¬Rqyl; cm~4V?tDӼD0__Qd47GC~RaGᆭeyxO ,o'5tmedm2 DYLIU4/'ʑO b/VS\%Ҫb"M #b 6I;DIGWĖ::mgϩxfIkI~։t140ed [_| $7 ~gipK򼩘)TƱG8QUeO>SS} j_ 9Nױxx3|9;ӌrqȯMzR'~ !`y\Ȑi2~a~m?m|'Yx:Z L𤱛2=/.8=g ~ךK:]ƕ/m(%VHV=D63 o9&* vҫ h!t.@\*I8 ɳoJ߇Nk"{& $I-&0tr{⟉.7vs]EʂhB21 I púm=m/:fpygOGHXXy =L6y-e70˥u.kx=?⥼vumKK`QPHD$wȸܣ3I$VFYkQ+iF8^dI>Vq*t!ʷ|t,{ C> $Ɵ ,#oX8+,[>j-Q@\xMrQT <1%^ ]:vB#OO,#S92S4cy5.mw>-. <' c@m è3h̑` I ?jׁ߲mu2ilue5ٵ݂ۺ`@2FO; ֿa?.4Wj)5WlB]["VdnWlO # _7Očk@յqqebc6"BdwCpfu^j_ _MHItuP)&K.Ъ*$E(|q m|&=X& 㳽yo4Z$ťE (BKoԺ g$R;,8,gĜR1z\{x??5SXC#}{%'#;8 a3*[Y \Z-ộe#*"7a͍fR0$YǗ$l~im=:7Mӡ"SQBtpF+$×z/4r’F$]+y`Yܺ"*R՟"xWK|;kixY-.DJ#x BWpr=cßwV6>O7ۑ`TM{Xx'X&5ZI㍑3r-M獼Ukmq%:v,p;paEgȢ2RmݟSlsE|]G <3ZvJR7)*2*q59k[oi>v6?lo:q#'&+*-!/2BO\:Ы#/_k:ǟ#k]~ U\aOa?(Z+gb7ܒ wφ~)cOɮxYr>}pҲ)$@xE?*< דE)9;s$(hyperorg/example/org/The_Blue_Marble_(remastered)_small.jpg.license000066400000000000000000000013351522421240700257600ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 1972 Harrison Hagan Schmitt, Public domain, via Wikimedia Commons # # SPDX-License-Identifier: CC0-1.0 # # SPDX-Creator: NASA/Apollo 17 crew; taken by either Harrison Schmitt or Ron Evans # SPDX-Created: 1972-12-17 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to # # File URL: # Original URL: # # Modification compared to original file: Resolution reduced hyperorg/misc/000077500000000000000000000000001522421240700136475ustar00rootroot00000000000000hyperorg/misc/screenshot01.png000066400000000000000000004156251522421240700167100ustar00rootroot00000000000000PNG  IHDRJ gAMA a pHYsttfxIDATx^tU?O X?%!RBW;{0t~*zF"YK@]yZ2ttn b5M Tj"<T3>}}ssNNNNZk콯}흰{]w41 3"T #k~57?mo'7wyk/(fh֯ovikg̔*U^^db;ۜ&dL}n޼_'k;:>l-6!'onez>1S4r%"}ںMP_YOH`LdHEqxFsAgRJgiP5Ec$E{yi(J9Q3T9 1cF)d4r.w ;>@ZnCu5[fxu;/z_Tv^ޜU^ӢQΨkbO ((-U3vY"޾thgv5iieg97\vC]NZ~;N;yZkyuV-OfY;=W5ѷλFq%ߙ-UI QiyGe=Xkl :)]>u:MW8[\]sLlF5[<\˽!Tb˨o˙aZ ӷ<}za=j=ݻFe=z~tYb).5y(1r=2|.{Co쒐Ynl~//:K[;HxMkjpoKЭHԯu4'r\=kV=3bE{\ӪfSc35۴uб碌2M Pa|ۼs*u*y4wUtʮ.ű%&2JAOv:۶uM[N٧͢2`UfH[KR`؜ݿ^nU7JwڴtAZ^(NV=ZbzՍjUZ?˻3:HW% c]/,Ձej|QwzIӾy9*-Vw*׃׺Uf##mmfsۇjNgijV>N뢆i*DQiQM hUuJ!e2 e Ɨ_Z5M79Ms/ںƽ>Jc ZtY^FKE[V/RYmtMɠTmG:0:s<6w$/ro[m}qʾfnCUT^'O;סU&ӴGȉyUY'^ts̫CvlK>zt uGiiT*oL,O\N9.FWk}z:^+fQWN>_2 7P`'֧UtvrU&|أSS JAB5:Urf=٬Zu[|!jAK}% O=łtaQ3??!/s.m3^uoF5g>;V*[6O!}E}jգӧ FPzyUJ]2;庩FMɂհAZjU̮ʭ/j.5;~tM= ÃjZҠyBި\&=L-jTr5^?z/WTe(Uރ5te~+KոbE U:~=jN=\uZZ<7ڶW`^ Ж;R=U}cz珽 sVزuy:Rj[6k5.Ӣuziuj󖕚nhm;5*Gu ]cMQ/TVT>۩S&SFg,W{׳Q 8eѪ PnW>5mhϯШg7~nk|]viNw-#R~~eG JvUCzyNs"Ψ:;帵Q+T`ZwnT=wr}gOϟi 4ٗWK|nU9sEo.Wc pfg<^~Cת*ߪ[*YFm+w4Jw RgB 2B3BsX\Sz'@P\.Spi|=LB]/,)T1>]/5?9D0/vk[}izz|qhos|i/UM7KUzig͵ui%7i˥Zm6^vݧ/ƽ=y*Ԧ7ޱ34JӋ_Ժ$y^\uoD;ZjlTT;*\Mj=jg$C$ q} Ai"r_'hϬz)5DM\oܶef$g8S&֗D$舊5}TeJU˷y9u]eOjC* --nVr<^TL;=\ $.iK^GQR0,eZwSFv^>ܷX3%n3+hm:m gJS-doغW=1W:k eأ-v{E\ΨZyrP/>\>wTqUl_:o}MTΪ뱥2k:#Ka/sw^ F^SfU4pOnh LSŻ#[AjTSq%1jWY9#p7xriXL0([cueywN윦ج7~j_OVH/_:p:SȁȤ2AKy9api ͟??<̛W)N/1Z^rO%͊4V3͋:>oJ҇#ϙifٚU:FcV0]U'k3<|4_r~ [~䭷-΋x]_Mn"[Pӑ+ m[=qʓeTͼ(3C1MumcvcL.oqb_/MctfD5+I8}9>5ܻNgN;[PvVkP]Ny׮s5d$2YJ/RIQY э]o(Kb9G-╪Lif6hT}L4l[$+pS~=n]u23AʏxL~T]TULʼfğ鴻7kٍ)l_|[UPWo֋퍪 ο=jtUwV?Ur4Nm4K~.'D]ruO:"gЗ*'4 ?]-6+N> solKwiyru;UwspKc˗yez =CIط嬯.m=X{s~s/歗yERysa|=T)>㫤f4Qyvm~œ5Y] °cJuJ]ƁND7}۴g:[6q> hw>icgTr)\XOpu[iuZZbÌ.Qoz9j߭$͘3- B?L)n8p=&oGggչ=tբp<B'dJCUߜ[$wg4ybL_>{QOTkU}}E:pgIӤUX1_H6@uWJոA ٢5}eZ^[\dΨ5꛹\uQMpY~~ܦbP *[`i,("@8sDQ\1``3cv~t.S[GEZvSC:O4jϳn_JsVիEOt4KQOD[╷,/o`v5C/aG9H̟>ujFe^u.0I˛ʩ TuX6vejQ~^JT2zOoɹ#3fjOЫ"J*AkZGȟU$+Ҝ[O8IG邍z{s\9tP^wD+)6W@e#&⚰ ((t{?nOң7K:EyTuGu,*Oz˶_ BM8\CGLg%δk4U\ٝ'CΫZu#m;˓s)6W^3 Re{!?Ęfgv4R_ EUiݳ{qv4]\njTw?ίجHS^zՔqQ'FRT;oҞktSrm~Flc`gСÔ=J?O;u|;!72vrr%cF^gvUڶC^iSSR²`EzWxIfK5k(5 w}ՇZH_U4ujʟ&5ejfʟn/Ipl$*sl%zIZ}m?ZoPE0ͼ+ >Xf`p/JTC%ҚIOz^ok}2lAᜫIFaUe9L6uG9צNiaj1BlV::ϥNyW:hgyy.%~[+&6" wz}Rŗ(_s -fؔcqJ?ڧFnggWhZLev֟"?J^=Wgd[wD}xώ$P&5@hGfdd*kRKG-}7ub3`K7wN$9R%L{JH5\RHZ| Uo\W+M6:MC߂^/_}S["/2Gp+]G 2N ߐ\uEkߤmej\U7@NUZRknü4T5S˄FWP™^ꧦγI凜|/G9}T)3|}~`_XF^F0I/U? ͺb w_J4_J.#Iu0V-ԮΗ ?ЫږβQoAiɴdX9oQ0;ej+U\%ٰ^F{_v~MN}?6Y>Lϗ!be걑ET2ͦ5is@wLKZF5Uhl۪VWUhV5loo[ܾ}.lTSQ|.ŻJڱX^p~L|!bY'K \yOSw.Mרe)&-vʻXSwr|g??=[o[3x/{0< ڼ.evyIl"`&eb-)V5޳<;R5n#-Q6c#2H oz㢣:g~~$)~B8P3Egx!75ᗩT33F/6kıBoWiщ۵(?R?MS!hk|E o~xˏo^dlo*VH]j*dTAzktS2m>[ڵrf~.جUzTܤ[W=1ꍝ+U8SMV-#6I5'4ťgj< F^zh:Տ^Lw?[kH57k-^l[uSE~7]|DdeXe,1e]^8+SAu>u~A]7GmSv@}/Q2d%^YfSM(SUSnB6GW{z\F{&,]U{%;jJui oh}Mfܨx.%* U: "\GζL[dgdyrl}nֲ*SMl^<]ޮ\i3Haelq XpEQϛfCJ˪TNѪGw=޳^8ר|1DEEE֘&TΟtP PXߧiLRtܧ*/WfN7*b%8l-M*џ줫gWiWOLPkL,;jJ$ݺ(@L)3/R]gOpju?¨g~qX(Ui7/)):t2/!{ϔ>-iԃSׯvT?>3-Sz$]-rN}?zn5S٥N jCv2jcSGԣn=ԡ t)L ~SցSUy, WS`rw?L/7taMJSσ{b`_Jv)'-l2.$g hloCLm`0Ie^}՗ND7ǫo 8#L!nPɔTm^:C@"u>00[]tjj2 =W=S&S|eR WՇ ?SgudBB[n~nƎ&Y6Lr!9}&>?v󒗏u>wc3Q#yolҏ"20X?adz(J7\qo:mM%\O_;]/N:gĸj~Z>O73cЋzyy[|\.{ߩ ck|s}uǼ_S]_npt_?)Bhvn W}[eɱMڭn֖`~rgާ-8LuPŞxGu*|>=ZymŜP7Hq)X^|A@m6,'a=<.:7pct?һ2i=|H]ց_VT{[Ϯ{V6a9$\VuyNSdSփy3oUW_zt'7Mfǧ^ӿvU= ;c.]3gdf ~Wşѕ%, KS]{45PSP&~D=L7wk{F>qO7(q}j_Ӝ7Mœ4)5ibJ\Y&gX` 6ӳ[>J&y=2ϰJ=|'ǦLپGwz*lɩe=g|>L9s.7/ HQn?Ҕ5$cx[wl?4L*OSox;L*+F_e>}pq* N{os[B(O6h734ǝ#곺;]m ?8~ >+Ud_e= |. 'rowsϹtcpɀ9  ;ʝ4v~N9dL]pʽ#߻M|LՑ~Iud-zk?9Ή=DƝe&Ue3>_v&oĞ[K -g{k ֳf'nYx&Ge,7دw?ۤ-?>1f|>Y[,>u&ݶX.ЂKUoʾrF)^~{>ls ce4_xЏն;I* J*gq9Wm?46]98϶B0IdJds>t_Nt)  e̜Ŀonߟ TKܬb T֙>uogzϙ?~|V^(hrw+%\/ +A`)_%k j F{r*cs@l 5s9IlR&@Ֆp_nқȼԞrC9+ B/ KS77뜏;c`b ЋtǓ׃2OJ#_ R2MݥLb,uogye,GK̹;2A*'x77>48UW&ΦK_I%{W?=1v/^ԳϚ*Gg&Jx͞0}AEc$ es}YεI'3)|>փLٞOnShx`6CbͬClLw/8W O,O*#Uҭ wX0Sp/ݺqbW_Ihԧt'c L]o tJ{7Ӗ>_NkUSwL: '|?&ʩ~xGM^MwS`T-T}>M_*Ft?8)KҟL'e`^G7ڻGV|U>ROOZ-$1Q=|'2H$YY֫nsl}^R3/3gw32-X< wUk4uӯ!È>0X#64cU:(|3JtM:NuҏOmFEfzבۿݾT+♺a7C2ȖiF~mt]_QCY Y i=|򵟜KXֿ׵Ŧk8eMvΝ~ So'elA* pKo_SAfpcð uo 5k;<吷#>u=qO&PuWg~|HÔ+%MEګ<ul}he9_3?\]Ax\y868bnsd zӎ'K ~ܻڶԤ " ~eM9׽gW:p9lfRE:Fy-"/jSɚfp2,2F6u4?u=H&_ɹ$eD1&Y6 WY=Q _+7TJ1:I7/P}"!_=0rNmƋ6kz-:֝k[$s_汑raR{?үRra8k` %wo( g8z4r./sq} q=06yNL\ay pUt}q-$0LA瓯ʀeO: t?@3 QlI CYɄz.Ɣ[2lA2'_FϪ)\b&52fcKM.:7DSt=O%Ct|ۯ 3Τ f?)NT^=)e!L=g7&!ϦzvovzBl|v6T2=6MZ?jXUz`ޟ<8:ITMՖOuSKg [<\W&AO~r'gs?4m|6N&Qf8ߧ$&e{;_\N·y`ЙTL>/Q6;yջVv#ˈ #A*A*A*P8VQ0Lo?*  87TۦZ|s. 0CZ]=gzi9ZtUYpYMsZ.TaI%%UjR]#hoݪ\śhXՙM4FUi6uzvmrՕAĪF ;j}uj5 Uי4P󣥪TmG^Z[թg(** !y34N'vRVO0?FWϮE7ձ:/?ovxLRO;#zNt8Κc>/Q6;ƮVδagyonZO׫aAG9=|lk bU}m=7v:ڬ['-V4ҨUY9Ԧ%uj-w/ӋTzI({C *\`dzQTkFl͚2L,/rPGieg_hTFQ*yiUe߱\-PiFi[iT(Z;0[t|*Ԇ?-M[nM0r;F'?w[>ofi啚UJ sYun]vʵAo .hPCpi6G[ռɎi}TQ;WTe+蓪ߴ7N&r@=z}3xwD]{[&j^|zx,ujucqOHh'LtH?_)/*B( 8GuDǨTS<َY{TŎYJTbG5F%1A# :Hi5vP̹c;YS "1]Լv?2씡LH0dRՌy4*IGlqH O." ٙڱ\Uo@ZFDswN S]9ҟ?YӋ3v,{5T(l{2uW=5@aRԾm"Mꅇvv:yz`~W&vjީa#ڳT.S/V ;:Y1A(]jeƎ I8#^6ոKE DRL(צF{)qgK;kBTm]f|h'~utjCue'Ј Rl^܄3Eyw:,n\oTJK7uיִߣU-곳bնT'줫TuUf\zzpVghieYF5-f'̠TY?7TMј!/_)ѿq}~$FR'8Nr\P?~t}TM |YT֨kVjsKmTvVߏt1g.ИiujkyJʈʤ2_M5v&۬tMbT'tD*=EeZ6]_"Z:/SJl֑שzBuffZш Rxm(o%ƔjJ|MJ M 0Gנtb\kt|ۯ 3l&U0أ][jkh @5 `83jv BTYmЛvgՌ53 wErbXTY)U^FЬ/(0TSfUEWjV͌rʣj]OVV~n|;2*0ffW^";R~)?#Ӿݏ_.rԾՓ$[)X.Dʬߩ9_ߧv"1l9Yl(,I7aJ#zH]VbGc|ڱXl%4GgEgdY쫪)%- ?6šw΍}OwWAٻFfX;ޢ; 5[b&Qq;z m F]vKk˜ -5[(c=ϋ;hgrorӧ:Cl\cE[y$;K}`p2R۶mNocd߶}gVt9ƙc'䆩ƎSH nRͺ"@`nhƎ׿uge Ѩ=VtFM :M :5M-`w骻$:Qfr@QArh鵟ݫͯ؟ Nw,sUJRL  }"{T ִ>>g2t>-(QU{qŔ.])U&@+pͿ~g^W2#"*unoSu"(=eҪAͪ?>)ZARҮQ4j;mȤ:ޢuyaL&t]bʮaxr|ŽWn_Qu:8[<UnQ ŰL0vy]=/|}`jwyȘ)UeU"omϟ4i}:iv|JfҔ vzy.lPf1xTk6s P~Vw<'p;C2 P}mZe˧J';b9cnjk}աVrZU nVU s͎uzb^%I)tGSRp`xWڭA7LE>+U'N lM//Z)U6pfTjTᶚ4]1Xr\Uu]\zYLٺPGJ r9~Ͷtd^Z_N/ oIevvi ~١}oa ]5.@Ύghsu,c:vEGNc6-}^U_mDwv1uzb_FSuQҳI_|3?쩔2|8B2t-**t_|U3 {L0)Vj5sߎ`2l34"*69Pp3Vl~& sut@Yb EEED@FJg׌jn~Ɍ);H͆:y["wm\ m\1քۺh=N︽EY{涪@?JŪݺGeur6Y0iZ2Sv6^nDuW}}J79\ޖ*ᕛon} EEEA;Y5clsF0(w 92wdR+unRvFn5>xv/juک]^9{T>% 痮L3fL*TM 0Gܧ}sȣ+ ݁$@:*(fhlsv~qJgiތCk)]ytRVO1zv./Nۦ]=#;InD`=2v]q(v6')c]_]}<gtl6|LtӱSjמz$Ed#rZ\^딛a[`Z]";/sQ3az/:Phf ds>!<DΩL* 2 ͟?hL ` / imv:Zhr˛Ԛ$+f0dlds>.ե_Zg}K֕9 ?b87)}DsrV7}ΎG@̅;tך$@r Ly4䦎#H]1d86j׺Aϵ蘙7AN~aͩ6m d4F6dqk\F 7ʪޭѱW"vF>̼z50wu7L*r˦ds>P%He՛Re*j`L̪dͯύKEe)*U͚1}تfDfTFJ*5٭F_sf_{Dy}e?bna6o"۔ ~&ٜOaw=۝vJ+f{5?&xr ݘlçEgSe/X֝Q(L_PUk(\7 *ur.[dE*rsTd\3*gk/P:yQcgxbjɏm*g_8x}gB-߯/~vgmїycw+dM69|>q ܟQzߖWrϥvӗMʢݽ˝3GCM\gze(dp m۴uVm֡}o r:餻I.w0`WNplf?f^X͸>~5y<< z1iovYoįCLhTIwNX& fL |rᗽcKy mB$^6U/z wr vW ҕz8H\ g-#,clT?&ʽQnםs0IoR )78BI'ԓL3{ 5ڝїK~lg{Eh;F&PFsMJ6Ig9_Zuء ^K>ͿMlP意7n 8NZA0nH^rK,ysEA77ܟqB>0/7FGs X6ױeOj"M9vK'67 OVۗ42M t߫n6 4iG3n.P6a*9]nXw 7g}MP`LfUUEgh9ü˽[gnIiS-Z5QmLScl^g4t?Fx:?~# |Bv1͉ɈױM} DqoxTϵh} ;!02R b.W<ɾy3L8po/Oͮt(WtuU]O?2$UnQ֝!0 3{VE߱S7ձ-:Kk[Ǜ^T9:6 (P5Ec G:1lJ?x}a.]ۼJr_ӯΉ~&]=cm~a58L= /+Uuq*t߶mts8:@M M:;ƎSi/zCL0!c+tw_v_ks_K/mn$dI:KLcdM= l]6ILfS58ҍrKRwr^G`t& /WKzS;ξsl {yc'_2뎸19slW'# kݗ^Y^7^ {W8c)nS|h'cF瓈ivmΗ tg}nW: +N(@QQQVo3R]o2T;f-<&v N12g>Db $:כ;I*+ sɼo7bisצ R٭s3l)ֱyzdl-9?S-Z2=6?ݫn#7>&g3?:|M)6?ۺ312y.Κcd3`voоIԾ_c'<;;~B 3R;;d}BɌ _ڐ6M8'651;8-ey!ӱl3c#9jܠD49ƜF|[4c o\wε{+L*>2&\! UTȍgG5eg `@`Ȥ@@@@°WMhXM :HH0Ryk-!`e~ ;ޑVn`e\ V0/ ]IdEuy{7^n3o&FsOe78ԄresÃ*_{ȷgXC]}CfÌ-tNmn[eFSkKS:M//oeӊȴU@EEEA;?S&iO3MӤԟg4?myKߙmo67*rRU*qŸL ډ82^nwv284FordZn@fJg׌jn~2sNfRSw"}gm.Ӛ4vwxK_v.)QJ~ے5ؠ9۽s'W"Իsix_aV̶\y pMe DFB(߫А8v3z"9uQ;4I{~ vR*]d,]h^]6vSg2&̣ލӴ_J$rKX*ٔ[X}[ja~/bFn TS|{P%gԹvڎikJ_yAC-;]M /*6cowo$b7'Oq]IC?k/?_ gkwwNAȺRg=ߙ#j&MNwD"}/WtQR ί U,VLe]"72 McqݟݟC%)3/rBut3-Xv RQcGc7?/jO2+708 Z ak)@R]t?U~(N?mZTӝ5*ͻF7o5UvXUn?guTS*1ӓʵ Tڥ)nso^5ۏD;鱥wnǜ.O`>#6MĮWrnm:9r. VTOք>3P\+*k<@*o}TBYBE%W?J p8вT%7i6u٦zikm'i9-G8ڥ?F˜}'& 5ѣf5T_ ߥ5_(MפnWݤi77ѭlU?~j԰SGBwDTW6M7CWN?YOy֘cӑ2UAUR=Odf|I75v{Kﺳ\gߌL&9ab/\c&uemٖX@S|NdM-O9Uj_QkTw[}/M_YQ3jZ &+a'ro"Uߗ>uWGn>ٗ_kOz]5sHzPD/I;_Y4T~q#p7uCL]K=u}m ]}ֻNֵ ;m }t&}` p)VՃ/?z{k%A \,jjoҵz;/\2;+Jq6~[yջ_U zm>h:R jV5m]ʾY~k =j1}^+go2ϊUYfU%r6mӺ`cD)S}/o=UFҎsRALLS7M#,~ϝg[j x*~?~fUŸ] +u.^wzD-w#Twp͎ 6n`+_?QRA6PxYkZƲ)79da6}MFt2WKm=˵PNƧWTGGkڂǵ{`J?;ةv;ZP}Nb=ܤEv2guE}_֫l2ml;V45&=`Fԙp-2-@Ao\#'x`Ou,X_87L4O]WVUY$'+:*U1ю̷:t@==lFT |\xuVDb蹏k?ӢT웫L*lF]1vEEE)5_3i;'s}[jTRM۬ޭ,>/Q6wB-1MХ^@@i~_C[TsI^5I~\>QckUm@M<[voQwM Uq;A;[j&_2َ9׺zc7~7K]SSDTmohzrGR|YQ>~9%ɽn֩帝7ZH½ImRݴ1 1^ۤ.H3שqapLSLϦŚIg;g .I ?Kpi˼c7ř4>OE,{p*FۉRN-l@ ](T2yHwY5s6?[ $P|c\%4Je6kWAqX+}Ah`E.F5{Z$0U<{@20X_`Bmx=Pi&-7 \or]IZ*MT٠ v}ζCa3R 󠐟&MeYr)״PL!LCT>`U}54M9:~NDՉwhF%%vFgo~k  voKsWhGl23 _huMZ17y1-f~>879Y εgD܈4O՜/HEifYwk Z:^U?PGwN90Rj];$&W;VܩM9W} |>9{&oלkpKmFmoӦD1[^ >ËDm>VSϽw㺮\3ǹ/vnw_6'6Ώ  ϨȵN$g_6B6ds>ʴQ#_ e7g|&4/fX;'edZOUF6P&2o'LƤ3޻cle#_ϐld Iu/>HR^Y=C2Yz8}h{Z1L /`\"E%ά9_N !۾: kݗQʡH~>yZm"6`b%ߦ^kXν듐^`TdzQkƕI6'z0 P_&۷{/kX/fX%;Fu4_ 4gQ״9#goPE3$ YY_UǭN9 '9wTմbE +lF%/ yiuwGViKr-QHu>^}5$ep_kRLK+nfeP3B5%i6zm~:|}`GX"OUGmϝk+5v^LA!^ɗꎑaG=}3ЯtυeX 9̠FW̽iC>a@1g;eૣ%s/Ϲ::#/A}?n| 8^z?QP7[sx>ROr͝ŵ5ѝ2/3}Hd׫ XgZr6~֬$n/ε듌[n1pؔ+jQ'z0F!fTWih7ݕͽ6!O Xw h` 񃹦C-\&ՏUD7@6]\\3d0z =k :wadQ U6BO!e}w=}gbC܀㪇$@<q!σ5]Z3W*ҫY]64ha头<&hs`|щޛ8xlr{}/3zPx5yyO [ 8a&+>(#y/YKdɒ 4tʒy1;M on߮g64r;R7$y p 龉 mSb:hx~v0)T fB6'zP2g@R yڱDݾhU5_=a|)ڻm(RkJup^^('V!՝\}ZтT"H;sF6T9b:d_/;{S }$N% v6hX3 ڡs;ퟻb>>ԃ•ͽ/;Cs5=ɚad$ѡ(캓:ED!"H5~>,LVq#];+^ Pvz%5ץR6>F'v;8tYL5M${!'_ ZӬFҸlss~0)sS̉9_O^f1eOes/duIp׃\ߧY]wNx<2șszKҜ*|rJ:uܧ|&V6) ;j?<|u66m2> __XX%픧`I!$1gH~ݡb2 wVY1_O9@fȤʩ^׎Z`t4~U+;cgۿCMI^Z3w;yaeq7 w;mv8 Wa\Pp9$elO6es/dM> K=4z$6V s˥iZfSuoҝ+ώ 6sNٴ"q?tL*P8Ȥ`I#H#H#H#H#H#H y/Nka'W!>?k~57?m)L3z&n%MZ!nV 0I 7xע2`&VBu7 tNP6jz_5˾i00 ;կꫡ2a P'P mXIo voKsWhGlH0 l@;w3] 4wؠ U9)$IZ|mο9W ز>ަrDf%6A,!jsIv0)ڦk?o CߺvpeكpY2>17<u (fz}b*)PI~mRA,zg+w --.ŷvJf[f}U9M>݃:&P|SeO*;f?gߓnIs7Hջ_M+V6V9_`mdwGViK;.ϷB> O 0ID1;bw>ff^5_9kw\|&:j|u&u}jƅй1SW AO^74}gzOk⽥El[ #¹::#/AҐƍj".&8wO=DAB=6a) sƎh,],f5+v5 K6ՠ:q<ڤ,7@vNdPH3;!}zJFcҸ>E]ϽM_dzqs ~_oYn75Q R% /Q2N9PrfMㅦ-$gk݀ u#\.Ug{Tşџs2{K}R%vPί U.ݥWhݮ rR염*cر؉6(gڱwG6dS|Tb4rO- 8OM5묺w3F]ކpqF-t6 Մ@"rR]>2lq4>p>{~S7d[sU&uiMg xoD{ 2)P]~q4^I6x%: v{#:grU.Bs{? TY ;Wi~7ͩ_۷ 4|B>lLQdZ=3Q+Z5 xMai0W}Sk~[Evbn : {̭n jLo\鬿Revs_NuiMYC..(1/hUpkB{ר|?jYd `PǦFo+TrnWC ؆B6m~ MQ^ʹOɐ;z#pكv_jMWxt4TCEJ6X+uӠ~RHClP& B>ts IU K;u?YB;49+P@۰0u+=˖::oH߸0<}1O*5f&^rf"ЬRhTpyQܨϨQ {a>>7zi0X5/Do9}yOf|^y)~FYm%Dk^+JNhΨ TOLvkF-ɱPf1)b|O! CK]^7s9Uj)D7ݖnF ]DMʹ_]?4{Du]*5}薣S-:f$T&v2F-Kd0ۛmKT]U]e祐3V6J\C[ufvH":~Zk9oyfA!Wl$%st׫^5׍rm^ssm/ڦ`$K ֨X.UԞΜ$/ K4+\՞^G(k \PRA}[ZMj_ |noKN UZ7A z?a~nk +=R@viejtDI]U[w) Ø2e^9\nsT@i([v-ؼ.Q"Ǚr_'fh-W=vscϤ,νeͫy%jRmN|S:56e:}'A kP}Yއ5sjWs0/o[5wIei*\yTʏ.ciS={b lfxF1)3zlpۇN=1pP%@ Poroس:<ߋ:?Ʈ}s@RؤI %;B1Tf&OxT`&i}j S.{Tv:s7R|c> f-uoҝ޷ܟE^.Kr1Wgp/^p3=jsB~W&>6թͼDp.D}٧|kRm'v^Q&sEV9[u^\齴}{Q_&eZ r VՆ^fEm:뤭K_2U8{"/-M\`o{۬m1̭o)[7brڶwzǖӲIF21/@;4 |g*5|V9 #ǔ/閷rK_ǹ-.5:w]ߍ|9?㣜%u~|]GӨ2+O1F1ipSh۷՜o5#M=*^֮k~ ~c[=rlNrO4im?gِLl:mr{ӒHT+ w5WmU?˖8M?;:[̖Gu>]oPWg^.iYF6וTSΏ~^{X+& `Tϭ9Г9iHKpW$f_XW9?%}q糧QVi^;ef6LL>9 pcx/KtI?"ԄqF^Ԝr9u*){$fH\N"${#;0&SƩ[Zҹ3wMg٠na&J=LfF>j|!~ &0t3b'cG'wYw1 VWY?~ `xQsWiVܹF}hdܹPUZs 55jL v4*܇גM}^B\?R}/g߹H{~`_u}˨cɾD}%J? r ?.o0/* fgy/fr($`|qL>vtyaY9}^`7tC`a-r*_2T@a1A*Ӥܦ%KƎ/Mf"Ή0%ƨ MxC6&h-YҨM9  v;]}K6uۥlO:a6u)AmMMz7閯o\VŠ靘 *`gQۄۚwl0sEjG{i~_(Ʌe2-U̔h^I # 7kf%4FLI6,#Ÿ1:i7g1 ҉6'\ԍEHP0T[f hއfsqN1 dۧ4gC6M fdP9u3e^߾Y6e6m\i `:e㝛JgtۼWO2[ߜ$YgIeQ72(z*ν1ʴ r `}!K?z5ta!0'Q47nPkdyP^ǘQο^2g (0}9ҷeq;7N21(w?wUuF_/;<|NL696kQ:7$gE͑cOdfԞզ9- _ٍxYFIքZ:&e\Y']Kf$.@){"/ßL.FZea7:״oK}&+ӹ7^񂦸2}Tc)2day'\,Øir{SsFezϟ Q4mЩa'Ϊ6w{߳\y@ ;fTkzT#%wMr0V=j\UM\uݨP&ii -9n|}npCt~e|}Gcd >s-g4הY\K-lx}(RfvY'-\:\{ʒ6Hj:Dxʝ)qTFz]_J.D:ƐTPpМ Lsgbf1p0YaGۣ#لa6GTLVT}w?d|SLpwmǜr;[/KF9ϐlTy@Yw-dt|ۯ 3dXiƅv P H@͞ݯSaßocv T c0XI#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H#H(7U ˾iN^1epI^&rul!R?Ԅ@a8d2L Ԙ4 |Bb9*uNT}/ϝ]0}SW/;6;[}Sc4ᇾul&\Lvush;;/}~vι U03~R2:c7*1&vEnOmq3{4~-Gm OkR_ܓ`?Q?<>#tlI - NZ>6w/x+_4 FDd{q>O&EeD=`:[iJ/g _Qw2~%l*wuLsO5c1 L۞0-P/ S#em$6z٫?}wlşџ3-qpc^H(^s㥻SqlFߖgQNuNIUFϬ;#@yS#uQ;2dAK>|?k~`k^MZz".oDz7F 7 i{۱1zDPr戚u"B0IG,@ۇT˾?z}b\`kr4 -cgb Z ak0dRCLR7qn)gB}bM}\~3*ѵDZN7T7Ns3:}W\]QX7uCJmp/GI|اǖ֫JmJUKKT}REcfթ;kT>ɛwYYs3:[# RQ(H5^X/:Vy{tӂmy (l՝f?'03 Lhߤ?'5ttfM*RQK6F5iyzKgܰ.Bn&{:ܮInnI#I9вT%7i6umsc&;Ng}w9*EMAjU TO>n΍QEjl٣#f.Yu=Tũۤ[A;iz-L[z4~2_תuj;n'\g~oLS}/5Nd&uX}O5j!;RsZUEO p#HG&M[heYQ>}쨫U߂f?ۢ3^R6yviϊiem$ \pI4iwoe*K3:YOeZޫlOnTm(wZ_ E&TWegJTFzAj }{3\4U_T6h^j_@*|Zys U^w#My!]:H)fpx}oFeY?:1U/]'v1"/Q6w"jO@ '4 C_PaT{Ln9H7 $C80ԣZPkW[HV/ pѧn;҇vR _T`).c)oQN;jm>ƅ*l.KbLKOujOlTw{σh6oG y-{a`+Qiv\;v`j(Oxo&ɪR;nQ6GUʎo٬&Ԛoիٛ̋iw<ιcڽIT95uD[Ur[f]0sG:ռӆ~ץw.qyNKOX[gbMdh_7Gz,[;|cνXž @NVCmj1E4f"nY LַKDTxV,I#u^;fRٽZ$*Uv"Fbs3dbU<^ _G ׊ʴhkԢ2OWVhVoM?ОWՕ6TjGy)PTTi)5_3i;HA6]yS]ALudRaXs}>.#՘f Prg>><_u6@%U4I+ U3+mB p^J,.;G.i[V/ѕ98p [ny<9|@|X} 5*Oh-4JeQM͈Ȥ2ު%Q=ʜb~A_|c\kЦi$ ]kfhFhxz]FQZ*Ij}uS|U.lPKušz~y6#{L]sj冧6j׺ϵ蘙7ಛZڸb*nHbU&X|^o٬Z8e7ӗDK/aҩjyna81>2<ɕTjfd>;k;a*]ͦ e"lzSk5'E00~~YvinD|Oz5=&,K۔@HK 0v]Z/c*Fh?{ y7>bQ<$mkz5̿͛]*ڥ^;s\p?T ٱO_N,A_\Lx}9&6KsoЙTD&]jtoݚ36:pGC7Zv _`(&(3DZi efc 5Oֱ"XhM ^ (lGW]נM:`fȟAڳ[mKתa 2uhOnspGH%;~'F kSBl`8t* -$&?H~nW&'ܔSkq>փ䜲o L6 ɦfZw>Ca)R<ҕU%|&o!!փJ?Cr>6d7:5ld—ja﹡z>&$\172MYeR|^ȴO&BH+'31'r 1s>9dNM^#-[wHgb{.i!?Gr<Ȥseb%=6T?k+`VC6P9&:O^1_1t\;+jKy}Us\P܉ُi%ר~X6wl%sc0>VDw:'؏=s8B" |2= p~gmѷ/oX֝|E>ya}r!_u4"gH1 EYwN8gÿ7wo}Y磎K>G?,7g~;쟉?C_2u.ynmn c\Ӭ- ol^KdWF߰CT{./:dp3z衡2P;\Y~J&ΒC3ʋ|[g͊ދkKB鄎$ i[{Ě4lϭ7D}C0pOKs6-|2uxE1瓬 ^Io{YcsleXG}M3x䲿zo߁ʅjh˄q^Ed{!,uVGdpe=8Mj6,|&w m}#ikrGV2d?' ߉eG0Bb:!1",3&8pb0::Z`9x˰J ϩ~լꚙFWN_tY:_C[+vCR~ē~4\/;R}IU.wXKAxkXrr[&7`S&bLr)m_- D{{woˎqkvp?NI^C%u%s(̏'ô9ر\R!ӗzAnJچяs;.J4W{QyL۹U^]GRcXgA|6qn!J4=sim]O\V\z朗;,@!H JN}ݐ oW1k* } G8"-..~j:LDx4K5ZtugP0*{ѯE=iu+b^uzrtw=Jm8җO2"H1|SsyAm!9mGoY|iOՃ^JG(G/걜=.#)O#ι2p\L#R†Qun53Y<(.xͺ^,~Evuz\1V_?ū*Zy:?Oqn';&y-&i&_EM[6m61'u$^ScR&WN}arD2ͿWw5Ȯ| ǟstU|z?1mnPʹ-o}K2_yS8nT$}$Z`O͏;tggj[_hDb۽TͽZ|c!_~]ͫ;^LPܼvS _&N(;3Ei:O:~U9b펂!"}UNzEIۗTS׏:3.ifTuK_s`H*\!ӏi˜PV-dǔJ3 ^8hz# k7:NYt6dUGo}X/*h1Uex=O~n;\x]Ϲ0N\oҬ;eoɞj-My$)^䱟;{Hn/38z]ϒ,:^t۔>ܢ??&s糃XbL5;Mq{*k Ϟ;btȳS  T/$SՊ=$>9 +5y+} ՙo*]WYd_5gLy<^yLi:&h"J4E{;9"ߢ Zvi~<6ƐrCVShiQ8ޓD:2Mù"_ V2ғ#]yʑqK4u4gT_\F*3<`4;43ۓiļϟ$Ӌ~'|b{HFn?K wKF@΄ågA MZ<`}mmDԝT)c]פ_ k׮]HH1L{N}M{OB#[ؠ5Sŵ18WN#^/ޮI3E7njp.$MWXٶiqX3-DI&mk=~&:9[0q-m$it-S&Wjfb:\gM p/#Z2s/4@ѾAМb:-ɕ)'h%Tz?7Qʹ˃,rL7>ZK'M|S"!H:Tf_lmqNlL\'j?WQ s~M:QFЈ15bkdTrש3C;1@Z?k<ĔF/|4WJ%~[%ԤVz]3R5i~imM:[NW2a;z~@!H9m!&Ea Ygj?h ])pVs,_7^U=t5,#pY#]FRQ*MQo) *5t^5n @H*46U8՘WRi?~D%z}UN5N_<U?KƩxT❎x']Ng}+xvd*L*zLcz .R]i!&hӦEM A*ՠ#L*wݢƲ}/w$ϒ#UFW"Ĩ̝Q.3{i) əЙe_]@OyG鲹,Nsu9཭Єz@0ݟGil? T(aS=S|7lg-RՋۭ=j~9_+UuEs(Zg?Tv.}k7]6rJiLݗՁ]Yhi^O4 ;a珖GכҦ?*m:^z>$ ݶeM-aϕ椲vs3miKI٘3+Ts۔d?y'wK-}ݲyVv6$ʔ7{?/t+ A_蝀F 5B Ɠ:eݘiK;{^@L4?ҩ-{tXwAOiu׺oyAi`q\J{<%:%I9ڣi?uhhmO,*-}Bo3-]m}Li 1ut~uh>(~rfЎ7xb~9|޶$ż✧HӆDm{ʴ*A*S]4[ j,;Yn"4zm{f~Ij_9IS/Su}`?ze~C7ĿFi>=JxrsJȵE_>d'A^^oAtv!c <Ajy}6&84YEUs;"6$-\6$wrld'ܧ@bowvB+Ͽ0u6.pv*@"^ԦdgяO_eGi.Ic?y~VH9Ec)d>v} N?^|{߬ Ѱ[ n]>ӎ}c77i]ex9S=_>nH]~ra&Ҷ4E [y*4$ _ OCVR&ɹ݋>!~2ʃ<ϟۃxi-$-srD!)v\G A*}z<ܫ5`ȍr5rwob?Տ:ǏS,6jɃ<-Aw9q:CdfA_ꁧ|,AiIDx y]'|+Dd6Ց۔b=ir}p|n]..[zߑx5r-z;< U8MWhmw>Sc UzS@Ʀ?ſӜy$S^Y*ïNi.?|fmȟ׼bY!+WIL)O㐨4@ H8 -Ę(m4%;O3q륟!|KYb+?LǛ̧s#:;F|+nhLUK%=OA:*rI5,;[_> uC-z5h o g@L)DLz.1gVQܖ ( G3=rO:/Y!qͺ bT  g?ا^ŚM8󫛯?~WoO=viS5$^`ʷ4&zv>*h@c1[L5ȠN>ڴx\z06@+} :m'mNGc\1< aRY~:"Ag|wZǨx~Qi@A\(r{]hb|V1$-TGiWկw?TT!ӳȟi>2WU81`(kC$ʷD:'Y!xYgOń qA-K7mm,<'ݵ$)oZNaR]gӷ+OӚ/k3:wר*3Z'Q5k6MEM}iR cqI\1%=O^ׇ~u_>SMNOEnCzJѡtp_A\(z{؂`=osUˎ̿xqnuC@PPbx n)gcϬ[ܾ2#%L!n$z~~3YԷ4mHgO0[@^RD͓DӶ׷~yfأrpgJ:m X%Wȉʼnٯɞ;yyE,xj'\aq +4i/Ư⿿"*o4ON^(V>A#![^x e`m^$RFYEvٸ_SI{GL$~%.Oٵ!e;?ƫT,@ҵk.7}Z2L ~O7`O>LC W.V|#w;@2I#k{= P]IS$|)|w*˳^ >ӢX9M3bW0 # wR@!HC rWvZG ReejP}*S*Wk4Ͷmc &_HQ+I^"$Q*Mn}(|/~N= FROiFkU +R_xVgf 2t2pc+-Ыi*S3߱?-^ڭ|=a?i$IVĩ|kX Ѹs@1gl?&~ [`|]yˍWwc6xNǍ"V5:}9*ZRÏ~v[:\zzOϟNH=_Z2ݾ;*3mxLˣ^fF:~i+rT)+Ʋ3rCM%U4vEN[O-j,ϣ%-G0=gBTn 3>麟_w^OL#j5hyٞLFOʺL?(o_ʭ^gMiTGyo*;UݪdV٣YZߏ.m=ڷu?Inqs,Ni6a"$o(yEPU고;zwI/maؗKwoO.WT)uT\}w*>y;oS KN6Ug^ Kw[/Su7iQm'~ξxѻt[3]]!٩5%z0=hG:s^ǔv?E>;eKq:=[N:u4kyBOu\p[Pdf~5d߃Й6UNUyQ^LRM}ˇ Z TGBM:v4>Piс:{NJ\uNJ}tJt(>+Vskz"T~WRͫA26៊3!MN3z6;Bſ׏S: p? ir< du4ZЙ|šϝ:)D"h'"U*父~;m&*`]M Wh!;oq '߈sɏ&Q U*7hH*v7{bӖ?K'kQG\QG]wOPlF:$jїBkў L[~SVxLLթru&XSMunȻfJj1KoC饧:'C}'>6>I܏o{2x)-tOz#cj:a<q̾N˛WN Om]b[ӆy-(ӽ #`I>wD,{읒9-V1,ml@=oȼ13yΉ{S\~n7MQ~|{~V{Sw$s+פ_4Z|{e1bTo{7m'LT<۞# ط&,d77au'oSx[֑ϋsgncѰ:&zyƶԎz+?T"T@R1?Nɝg9D9mwC0ÇmwWoz](MQmcßi9ƒ4EgΈiBS_Mh>&Fgqڔlvfvvσ=n=;y&LܴEWaVU/.Euw|m{[@ؼ^j]\_4_Ïݎʓ!y0ͳ$,6ўى.c}Ntڄ 5X ɬND#=ۄNzJ;?^*'Tכ&o_sun# lv׻`Ŭw|`Te{Y HiƫT>x8EN[1[MӺ9cS5zUwV4@֪4^NV<Yk@,7hONi6~n򨣩:bы,SI:6in]vOAv0hs %pWxSzj5YO=txnK#cm;+̞0ξ]nшs V^DKvNF&m`@Cܗ^hL)D% /z݃~,Ο򫣗LFǝ&՝hD_HƷʷ asFn̎Zw%kI%GqE]E_v;Z\_Iw} y.EKuQ p%ߨ2V.O빀5VYiQFޑ>2MxGF`՝|?lK1ݮrxmJr<퉷@Hi#K$^Gԝ^umUu4'Hu2-MB&ձ%b4:-];bNN4QǾ;r©Ȳ&;C4%Lx n.KthynA=9 ':'|3> F=9G@LÕ @ A9Q+F N Z'?+^:уL| 8'ٙVmH'kWc <ڦ.yгLꜟ;Ѿu ^l?q|<5!ݓ T:^t lʣ-h(8:_SY2oR}qѺ=|1k<~iOEzZ4-=;>Go z*m9s;+3X"ƩqlX\zҦGN}Fqh"J%k-~\Gԝԙo*]˭^gVũIdyDɫVYt9vV#)Ĉރ}onu3*JE^/;\5t>znb6"QHGi ^*;lΝ @RٯSXSUiZ3ioupH imwIBu}\mn}޾muxӔҞt9KZGnԌ{8CEMϴ~w?yѱ wۻkj|f|#X֝4eLrF@R6[eyC߾)=khO;&$Xu;+20+3%M6[w&'Ƀ<`Gq=lmaiž#9'RGhE~_<1$;=߯?FnQcIBu>=]jziJ'˼s֓ti25X>NJ@kO|X]hR8Xyz$;$)rMZwz-Sg7.d?-zM[ƕ]^u42($iI0>-Ct頖y9m/QԔgMS{?z!^ m5= '#;͎6y^yb}(U_ps JvW1%$ӢpS5e ȩڳZ"5*?gs ^࿋;CV;5u!KN,sSg~::5XƙJSjyo\|~xyLշ-gv6X[~Hv[%8ig$ۡYuzAŷx뜑f/`[AVpR)|ݪ\7ŧGO.~z<(6HW Z$rzʃ`3:W NS@f>7k`bOn<]_k@nK4$\eIa$,, r Hީ|Ӝ\ ^?_\i R@bR@0A*T; wR@!HC r \m-dV?b:;޷JG>D9߶\ݣϫw{|>gssچQĹd֏49Ne詗Ǔ`xf})d=}Ύx=aBePfSovF9Gj'nv Qݗܞ]IxӮ8iayn#97Cy|{Q{N6#|\Rw.SooY|6oyPf[~V{Pcl - JۀB . 'OMR6AUt9Qs;گ={NFrJ)v;|;ƌNޡ*o.Y3C;eKwuSǽ=[|pڝ$e_Ϡ #κR\FUqj>"( ЌpbJTD}+Uəw@g9@ ҟ- `;]NN>0F[D.+Yo1 7OAc؛^ D HvFFYڬb~|Ĉ/ $2W(ﱛQP"GQ"إAΩwuu ʊ} M+wCU>mr_/H)].v<#DeDϠ Ot'1A+UguxcIu˛Z#;5zG/i)G!RSruIfDwft x3ܣ `>wVpE avʠcܩɠOHwd7A4VYxNnpSx'{*0 mRKsw{ϧKUiNI͇1*Ȝ>2NpD|zǹ/uXj324VпϠC .w7/0+BQW'*-zMFL^;xTdog*uYFH,keXWwSwJnjz*5 `)A/W˺E}0Iu9@B\bij:#Ɵ/<}N)W#0dp`Y){CJ#ȣB۠ҜmŅ5"l^o5u^5x*A$GJ^:b STEڝNSn+|Z)3 ֘x2NAhy@߼Ws8,;negHNÕh;+S\W7mܷLhr*-,}ZO:dё^:G*&GW]GIǷuVVoS:ָkiЩ@/vLMo:*1XBjMkuYk<-kTР#,"y୛NcGXeyxkyΥ,9Jvޏ;躠OY jfZ'EmOŽ?(k?gX~SP#?Ϳ]""Da< ,!ojځ*>eJtQ&Nǣ"T\UU\,^^;^ycDZٷ]Ցo.&ώʶɎA:&cKZ3@ .XY2OYEW pfY#b;vɘixv~nXDNujws=I=?L)T-ヘƺ(C.r"/65Eδw1D<0&h+m=u7`![Ǡ|'X!t[ =e5/}|I# &9(.FH{;/[FD_E<[/()S76{z:D6۷ |vYRŨj>2 {.Ny_ÏOAAڵk7@,#w;HN `m>㣝 d*g0P|Gh5. ^!EFR!ֆxPtK,Z00>з˼KUTPxrm&5ͥ=s5oZNⵤFc\N```$A*T; wR@IiF>|`MJQ†|G4F N|-Rijn۷|LR;k?ƩxTnU\mq<FߥG4{_hs_Wil?~σ&~lr֒fևrOKׁׅy昜Z\T+Z+?,ai5v}Cٴsݾ44Zuy;oݨ39i[U)}Fe||:4zn5<=o5-gtltX99ޤEqoħƳ;Ě*/?g2_ |>ќ Ou :>v6#ޏO_}{}:OOmHs\W7%!ei{҆@J7MUyqS9&_e[WNi `sj}腱Mv}K9mfkZiYy?O>w58Ujh84Fòg~r:I]3"mY/cbtqJr̐' ܢ}4E:hs:9¶ rG:=ZpC'9m?i7f:ytDTnخPsT~ݞj;a= Ee;S 3 RƆoڴ=B<ϮZ;B|\bV}?3W^$γqH7u.0 _%F4]]]Q,G@JttG;9TIm&PG_&j&VN@ ~"sI };I;:ݛ=diL Fvdș¯L~'tžJ{mGfc],{T ե>XޕZjfH#ɴu_G)Ҧ yM{?ߜƫ0UQJC\}GFzSQ2OM1!O=ֈ\JOo< Y HUh00yq٣k΀3ңհa5*nS9 ݸOF!S)ܩ}sKΊ=z?O!>y>ݟ^ʘ0Jh/H~[XTfn_*M'ChhYuqGhJS}<ҖO^`0xTGgm2= \^yB 1Z3ʃ<Ο"-7쓬۷_Cjt M#9hVI%:Uzʿכ;tFh$pͧ#ZeV#פLwz= +$|#z8K̠  IzOV#Ta)M'FŜ #R@Ԧ_+>N+}2A2Lyӣ7L8a>^ >Kz\cRxqߺ3|biQ}~Qi1O~4ʻ0uyA^O^oR}qO#Ø ۷Z`YAz2!=#Vi}N2HS[7]ԔgީUMtbj';r AV}֨#z=\%FZvclKjʽKR]L]'*^sخPmb* Ԧ7H ɮ;ZuvǛ&݌5]8{f2JjC<ׇFC1}|:qӣm,t$b;(!†:n{kQI~XM1N߼yk^'G}3}#j׳|oh&(`V:T7֥*ݢΊ \T K=_bOܢ {֞ONyG^ {RM~ߢ_s$neF~eڴ\SkjçT#jeڙi!OtV4:Qijo.$}n[i=;eM-!Hu rE Zmj6u>=]5iyf{eS>l_w՝}51NJ"Mh2՘RR1 H^ydz^:< '| ucH2eӾׯ{{iTV@1w$$74|}EN ޔXɛ~2o]֙"dq7}mzG^o*hߦdt5@_M-RU k-|.ѿ_zz  VO>=Z~ry^a?=ރMTFR@0 r A*T;JST>[mJ=Y@*'T-ll,XkI/:rU*† զVA$u`0 HhQ񐉏x4]ot=зz UN:czlӏ|FWhޣwCt5 5{˴yݪR3TT*T6[_ ۯ z6MejQcf{b[q'qiԇUs{qM{Oh3`{S7gVǏ*4=nI3q{|$eڋqy7my?FߥGF}cXk}{Zsjv͓?iٖZGTZ4JۆpPwVz7;W/~N= FRMu_#GQN0T+Z5iv]OEdYn &Wy&(kU#ګеjI'2 O_$i˳|d@lmSVk0pOuu[#w̿ysUWwu6TZvY>yn8244%aRԺV|+mIu(};TykS++󠵨x XvǙYn54 x]~5rH:0;u{Z!( v`Os|_Ɍ+4[q㝫+1;WX ˃ʔwu{ +}&mO̴,SC&7f^@tdby#ՐCCeՏ3oy70 gx 6ߊ^=)`ʙ9j$o/nL)9ާ7Otm Ē/I:Ўyհ|zN|dFmiQY#ѵ8kvgtl| 2u:hG+}aR-9ӏnċS߬!V7:U?t$sM9ߔa(0t7i1 ]|Z4qoQӤ>F2rcc_xᒴ!;ͷڷ굇H[?uĆ §8N-6 JMUUVGQhXF|SqTkQ_B{35_@Οѻ_g) }X 8 )Әi+HDY:̴Un|Ҝ?E.kMӯz`ʍ!Yq!a^wquCQTak^rr$kZ,=RZ%0X֞&`9O$LkQ1{d%]ڊP>< EZ\\ mQz0`tp뒮+2YY D1Pɫ@wR)u{Z!_gz44vF(K46E ߈ݵi߈ǧxc*?jM 2ouSZ"e.}بTi+j/k ]QtXL0UU`՝tVzC^!Hu)O?;.Zx9aQAb,1-\\Zathc˧Lyj.|YnО XQykT TH%.,骴0e.65&YwZd#FtyUHEm|AUw@BmUC6)ґ>mE-9ޤu54<] =bmj *m-^})}2V}g^gZ6ߊ\Ӷy+ > /(dәK-_$zT@,Brg+e_5є^Vɳo.rcZ|#mN`XE&FЩveISI5hu'M^[jn`xc p*8LkhSu5?Rkh'WOUva\5gqJ>czRRih"²VɻoR}qя#n@Yq~Mǧ9Si^%ɷ"^4: C:uwj6cݵ켤6XqОk:.G\o$6.D2Luvxq^%; JNڐ*O-\Pe2M|Y,ok,~-jMt?c ky~6#w*H˜mcN`!*jY|+jK^5*.ZnȞmM5._S4yn^cgVaNgV,Ӟck l85q!Siz_=걢HS`uIp%mE, 7d|L<dwpTYMd_>~! N$1Pu'e^tm3.<RvZ=36@KU9zL(rڠPw@_ׯߩGA VZ_>=Z~i9mPl;A*=u>"^dVK%*V_Ai篰m}gȲ}{eT_:NT#CsA`xL7y?r;3mz . v$~V GzI)T=B Mz2T:#ȪP炧;z^@WJ}c~_l͙fOOWn^iD׸<2Ϳg>dL(>>}{/-ϋ<;+h% _koۍ*1_рFŪhJFqcF A*@in}*;?3 hSua[Ds;yG7wvIx:͋kD.&Ƨ)9Og&^҈Q3L&Pm6i}ysԤ3 _c7;DyﭞZQ7IhRzʟ5y䏧5"󿰲6c)4^%TEmrfٴR~6axeV6Ķ Yˏo=4ˬ7VMT^`B 1B梨kΕ4Q;mz)VT_w EϔSuoV흞k1}5'eYerپ:ϯi}~;+timFm:xV {ۉZJW={awH͎6ÎuAfyzӁw>>ZK,GXʋ==L1ɀ?2X U¦+A*8:@y'Fv7ħZ*lC=׋FL"Zy >`ܽ{W%yg'hDa;]\+ȘN0c%fK#tDP@$)Hgr;;t ϓn#BW]ʺ9SeN;g EXA*8N`gnxu\r7;pcFԴstm);!֩tFno%&U#|9@N)+9*Jx|]u3e^HQfAą  ˟#t)eZz# /^9O^cpgTpe H4~)LҶ?ݦ\*3*0 {u' 2֍U5e_@0!=#RJS}) ѳ՜\޳v_Չ-㯚9ԒϛsbRz˟:|UBGjHL :j-4xL<5nu4n;0b .-%%k5#\R4g䮛<:ЎM[ݵw?Q#_*ؤ :E]aӸIa5֥*ݢð)bi%kM|S8A78BD=.4I߶VZhzNP=owT#낕?.ră@nZ'PՍi$cSBζ40|;GhzI#?Ye]bwsI=?4V㫟]Xk׮u#p) ˻x -0 NǛNMMi x̗GL풏^|;OMmWG=h涭*{I9eDros_TUS>Aȝ-sD[?;-r6kаNx*i&/<07>gEvOL6v \?)ycܛoqЩG&Ny_扶*m,4O`x{ 쨟|VCQWuߣYTwC;|8K7HC<Y<5j,k{߷X^G _?0q_h9[zz'=hm9Fu7S!~vx z섚tFw)߸lvfTݘsI}pni~+8a7>-Iuy?žv97I'K{t=ks;4^A*8KĈ/9WDMt>;çRx@=uDx\戭4dD`3L7:᯹smI;ݟH{ԅFWhk]oׁ؜̫+܀될73P"42|~_ Ί n*H9c'zo$ğl? kܲ۷+o|G}^GnYA +:t=Muzԁ$s%M}Ⱥ)^_=Adm2d*3K26W-y2`ec8OVޏ=ܷ,q>F켕AD. >C^v$KO|GIQ cKDO9S:hGfc9w.J)}@6Ҽ>ɉr> aߙQ7*pb#*o̳G{ t%| ZNdzNR'8 X/8^v>u%h{`^&>F"6ط*m>&~_.(|?=~)~=yPtW>H5!:ujS%_4F FSC"O:}tEuys|*-Іȓ7<.d-υSs.|烢 ;˜zuqnݻ/{?;a##N'14nG؆y ^" z4_G5QKSLf Twףn9lD1=FFպN<}1:u I)ޗ883dyPtI ܠ :}?x@ '037ܢ?BO& zFVc/ͩ {oC w/<0͝:|ٷ*kk͹Nd1E1dOԪ=kAB5ʮ_|L ҟjnhy/=O鵳^P,CڵkTnu{ˡ?+Uo7t̾j)zBzUڭ&~^emmݢ xWj{C㲴{qmso mloy}*]cTO4n5EӔz?:`}tqiHg3A ;~:G@][w>xٌ޿K*aDz|G婏?_TqnQ)Za)jzEbO´iihOҶ*M=H#g6mIN4u3m^wVq+g~t ) 񄥍nZs!E>;)< /rl4?\p6${9yO/FFwvD߷? +9wQV@ QvS=ʻ6h맞VKͩz]j:o+{tmeez2i޴cYp|4hz;o>O-k]2{b9"4|:Ly^^-.xlB;B}iefw'igߠs鏲.yl-mpAIжȴYeC2AJ ќOHi<#c#">`H%O3Λ4i|ޗAg(OZw:? Iϟԃ4U Rd!~4]8ҧ!<`XvVm5BmX+ڦ^yu&6mW3L "h?ӐuziLoG |}<FDzz:i+[[$?my?NĿ@4m?|vDF΃8Nz$mo5HqL]Gs'wG9xpr?JS8PeK%s=K1T} ftTVrb?TB mHn)+N9Wzw7컋qLarDOHn>Υ}6A+?l>/$}95_PQu>ZsLUsbMCA)WUi|J gj[ \qٿNB!=R{36~@~?ĸ/a +n{.#}?XVyԢ/! R2}+G۸^Jۀԝ"?< EZ\\FxeZdٷUEAuGw:n뼍j^NQ0"=No7dizm66j05f ^M#wˬf90iPR9WJΕng#G(OчntPƆ?m ;Sxu_czʃ4"Ms:4ם;W۪|$Oݱ?HSyw]YZUWW"4+a5Xi?[< _ߨ%ʨ\҇Mŭi$׺LH4Է4 }XR9 tW jQK;.Jj^r 0wtHjXY+..*iZQ.*mn'\6uJQwޠOvVy,k~K+5&jZY߫~dj'h2=kkScbПYW[l߬5fTf.;Gb4; A* 5_ z?/V÷ض-kr^ G3b3zqGEoPEqs|+G'(d6HZ<]W+WM_wIOi^S|kHYTur_\w>_羳9Ӡ^W"iZzܻWxmȽ<-ggIx6UA >)vM#MeeӽyEA*hWH\A0~WAӤZ}|-"GL5HsAp].4ʃ4qS>N[_w7iG->W.֠Ol$I8,1?C9tY^y|.;|KyA:/|e[ϬA_TP:FzLY't=yH*G"y)'0gX| <&Hs|;WN? OW:ӿ Q˴/ECnNJJz4Kȫ5Ƨg4MSs=׮hH[υPGzTAE ;E6$yj*:8y&߸^G%v}6;'rʣvM38QڪeڣcOtԃ4/~N= FRYwUo:}Z}L mAtBRLIL5ZntY kߣFF)\^yf?=iOr#4y)6=]h&" FǓj_8.ڪty,I"#֟S_4Uv.?ƫ]X#l$mȠE̺ ,arӅZGH[~L494FR36@KUy|W^iU_hYH*T0Ь2͜ ՘>H^eT+LW&:Pm 6!^6A Oʧy0hΠ7~2&whw_{nz3Al~O2M ~vBO +SPGI;.iAIKzn}$i:=}kC؎]>[$Q\Ҽ݉.61a75Dտ~ΙU>ԃA*KѿgZegJ冚LKi?NĿ@4m? `/ۣ%h;mI~*S#Lm*3|p~؏X3m3Psє^up|P#R?;ug~{2,iAZyAO?@Jؾo ڷ} z㗏OUaGr9fJ).g DO͖ŀU/SLn{iޛ\%RnWՍM:yϙ0i66gt!I~obϾ'ߚ""4)c4~E'2}\q:|S+Q3:C>YW?DfFm{E'oܲfdz;~ա$oAOiK ˃4<z:6оŪo ڷ|GYm5P}sߟ0̷zZs<60^\gpx. |ow8 嶹x2$(mGi+A}P/wM.+Xf~b)+'21$sSδn||+mworn}O 5 :Oi sy4yISwzO^.4<2mu.xQ<қ"?)5x.ge"OQ]J  ceHweeYٷ ߶ٻӐ U'|`v#?/MQmc Ǒ6my#UĶ*uq ^}KyӢ,dlhj(w4Ƨo-MMS::p N}}􄾱n D0(tbq1Pݢ;#sGtO_Üo*y3r" Jޮi S &MrHMw>=ŚLM/Oq'9Lٟ&z3&F:m8QGlwm4S}.[ 8Fk6&2}tmS8m)k=-m_䈙{.F4S_g]g?+mL<=Vq^`經O>D=0dt9^7uo;`EN}1٥xn|ǗQDO<ݾm >f5 *5kVVs[Ny&j&'!rව<>!HŐťUWy֋1so4(~P5EĔ_<]WQvj3 gLAEm/_ x^;iҖ mJ^`շt<-~52&Y幦(ev{lKӧpc0Ρ{[{ G N\?MĬLt=ϢyZ4݆L!#u5玧.9*9g ey:S LYۅ7=M1~Yxt[E`qe} Ho.rcZ|^.BV-#!gzo8?neDG>|G̅^f_U;ҖiZI[Z_i*f}WG%Mun'wKO^:犬ybYR+>]VT٩un36>/*:QI*6>M[6mYѰzpx;eMetD;NsC3ޱnrgEvw{~Ձ+׽/岓W%0%mTC'PpvwhNWmvsC;D6YڶtjNAYqүr[F> غ#`ob2y:ul:4:yԑϟjbI޶'s5,v=t* X! &|~MdW1]չyȜ}Ɏ<qF4rϫ#zK;Z5~qΡpQDa#i. P* vL_&7m}o{}"&8nu|W1R;gmWrs1<4Fn"0Q߃u] He8ߤb >_9񫪜/>6zz4-=;Go2;ǧyDo4YJi{sUm/܎pOID2AғrJǚ^/O? }HIRҤ-/sO:4yGwry4ׯye:n#~=KRND'FEۛq7gůO<Rc.e}ı[Xn۝m 5YA;;R0dCa!F9Ӌ2Rq*$ӟ ϨgUs.}#F5JsT)=SO:=#gmz)=em.CknLi RvJ_wg0z]AS`uUѩ CU>0~X9ww1cszEwGYyښnct)Ӣ QX1ۆoczg ٦$ i&z=yU gz=R<ȭ4v6ߜVSSoE[IEV՛['k\zyG,ל5ֶ}[|J$iz~!Wy'P{ISoit:dzq./v?VO+MIAҴ%mrnzC&y|z=R< Ƀ$t e=s4g\.ygRƧ_Iw˧rKVeDu{=ZFĺTWt?u\uy8(__r:ٷUKICrw`>te)^o^AR6_WN_9D->zj!_C{sLV>6E;͢gh6zP94`7U2PY?t:ضhd>%s1zedymyA35 ȣ|?< [`~sy}"}j[|$i?_^?*Xǟ=RJS5 AW} @z8x(:WרtX>gM'S^kPnSw-EtJu#r?z1gs+k_j?B:+1=wIX;ƒwb9>wo]ӛX]dW|}/ys紽$GXy=N"_,+*J>9Jn5>]ğTuNOjQ^)ThzڢKq^Z)_*ddm5j, ΡDhٹ׾:Y#,ޑ M,Ĩ^1b_ξe}3yMǃ*%ԇ:ݞ9풞Bs+D2Cg˧L11skA k"t}{k6u4z݊S14_E+c:kKoL@6F[BGy>?[D1\uOu漻~uU۩Z*_ \=FmۄJ/;4{%F"Yq4,|낤~ˤj~|5]SuLnS|Yo^Y}ROGSۚ1rEږN` e/wa?H,2q2Pp|ӅAnXޯJ55 #RS=Y~c5t}퐞 >?/"@u۹Z'E H%g,\;t;!\ZbFvn)q^\#:.~QNaQE 1LH*T;}>Ԧ#ł A*]ڵkWǏ*t{LzPhG\6i!HD_evicF Scz>Gggg*|P9jCt͓ejȤrykK6j450PsW1$QQyz>|`vzPAS {(p_h!DN#yt%SS^lp|*Y~˧ qj3ⷠiĴKk)v4l=gLAsPW3yS94]N~mF9ߜN=u[Xy:{Dby*_SM[pn;=rˁ"b47?dY8񕩧EM9ƀD}ǟ< Z>'/دs7[G '9=\H3{ig*7^P:b /_}t6Ï!3}umVO>]o ؜aSt;J~lA*i7,7hOkDg$}sΟѤ s^:V gc^~<@cvsY]!5D _O{~sÇtKQ7|2?XMGش1/Iu| A4;?aDnmI9'5<koY>6*cuU7yjjRɃkʸɷ)#\RӦ ֨H4}xݴV{ƞb3!j5vm/_X̌ڙ=D2QӴ8ނS ϯi}N4.;X*A#z=d5DJvsUG<#/X̦yj>ܢPiz?O:=5~'ߖDwwrnK#|<꾦toγwk-x"ψO&xd/'Ǝi@!.C֜tqZ0Qu[FhBطZD+Dǟ2 qa˃f[y1XW2v|@s>iF?Qz=Qo]Kp8yPeDg^S^F.`q3f٣MbP!*MNG>MO'oN » :ݳ:c^ubJ,1U-iXKtvd1RGMcם=1#r Ɏ?]C1nfom &!Q5Zd=hU[Ŧ~R%K{ 8ۏnK譹rO+j_(QHyCU>*sT\c06@+5u?_Ϩ:m'mMjSYI*O? }BR^éhz-z}*S<ȕbќDQ2'#CsRg#@F8QSXe򙜞ONwc=aS^uya&H~# Yŧu磺EeM| @{͝lw|viKz~@ c#+7!%<fy-S"4dLiu tuqAܼ{dyRaz̤p HŔJST3ӷi-jzKjZj=Γ (oՊ=u_y9~ 7wċK=6Ky30ꪇg#lURb ˚+K紽^#Ͽ KLKi7kTD#td>Q;1>D:W9!#G9 ak4ejb'_'#U~,w՝D1^TT$G:- w5Փ;ŗT'w ]$ؿ5 ^IOBGN+1EY61 2֢#wLz#zMֹor-ҫiAl\K_yNO)y}?h{v6H=hm_IS.t(yg{ -A>5vΊ ZO3VHJ@Jw*s~3zXc@R1<T7쓷Eg꾴kAkDoi1`}o=U7}{)\}_=2ᆵ<`m= ywNKr)Æh#ZQe92BtsG/FNs[T\.pHyk*(BOe6UnD:c?/u#yUMag"~^;AJ& ȎO2_Kޞ꬟vx7\͞UU]/ыwV92yM"*X&]C' o8m]b)/P=sGcQeM.3dam0YGzD $SkY#X=Xrm'WI j@ .5Цz 2Z_y&cIv$+QlK(Ed;)agvQƺ$/G۞u\ȟ_Q) t3G@ӽS5MRb9d"ry5~jDFmBESUN:QsDҞ"N钗qG4}nM|B[G_V2?k|M/3SyˑOOwtZsUy pP}] y%h #RR5a:ݬ|Dϟ"@3% !HūԴGҽ؎^r*M4ZO4q0a?H?UT; wR@!HC r A*T; wR@!HC r A*T; wR@!HC r A*T0NiJGnG/JTz[EJǐ }{,~fIV?i]tppDY;B }Ĩ^Kdu~~FVg_28 T0hyj{9m/! u:+4u~R?TS~^:{kU(RW_NP A*P>|VReVsT5l]QTRqryM4BtKLGdڦL[9ޙjIM/r!2Ϳg>d7tbw1ĚI{fi=Yo^>nk.O#^cM%E׃_ ]N7k;[9Gڟt9ߦyw?sb.DzulsD=yٓwӋ2k'?S9ϑo&~dg ? um|LN|ItײcO=i\[=n=ST{~(;fN*6I߶kh2#wM~wRwz:js;4Sf{y"g?m>'L CH;\snNnQjtNі?ͷw~N|sst:Sqj5*_nٍ7k:]2aKse{zfqA(o~f~'"N7٤Q֪<ގ8Y/G:ݛ}?qz섥 ;1C8ƱWO聾3pFWhk]eC/9䝌|M^wpFPM ؑuDψK1?g?in]vڽYP9zju&Ukjνp>u>xϝّFO#{jvGevktYbXAs:9 ;ޝ 2:QB|cAND!*dp{4Շj<~qXsM/u/G-;tG]g~Ol3yؔFZP{D9=wyDfުvE݋+9&&_.\`:=5ڂ^۩)d3:{F^5:PϘ ZCϡ:e&v0n\!IZy@$ E P(33˴ז3N|p@\-VP(>ŗ<Ź W]>B#N96r]ttטHlpSgyTFG_\HtvK޶QfcPܡF#:VC,3֟dǫf/Ȟga8stCAY|JԈ輓&s$ OJt|xݹvΟ`[Xz;Uayq1Mmړ.iL%Ku=/Mr4k}A+9GDTKl28#yvJ3; c]Ϙ-|^;e(F[zudԟ!HXvZWTiljjzq۠Wngg6EcߧVԷ/,MլOMO26@5:}5Z OW<Я. <җ jx (ju3M}˓V>-/U1E<4X>[:zɃ_6:mIdmgڴ,IоEMV)w&aye9F6>\.v2'9)ehwLcDBv`꛷#33ƧۙyQSgМ+[t]SLd+Q'&oxND幦 O Ct 2#3T2Ϋ"GTOmşPS9+RZo65 ^Q Úw"D }v,8m1-PN? ' JRIZLvjT/T _0hVCnG^72p!Hutj3_Y9Lev?ltY{j%#(MSu핯XUiRZ`'n<1vYnƫnv\G#Ks}u7SLy@ȷdkkDԷ Ht:IZ-zC?W*?xnFN'ȧh!{޳?;`V0c`MRoSkO߲_o"mp49ԦƲ#ӏΑyvA^#:SxݲLCZ|4 dZfy8x?|[n챚W;׷yHlo,qG<ͷ&Ɛ8>s.M$-<ڃ oa@>i$aVdI8y9FOi$o ˔++~x.8ّFU'#4f:+a !:Sh , -12#`ϫ L;(!;ӧEtBOQ%xj~D(GQ&hE0ԘLyzh 5uVNZbJ3h4pCO/wF9[4_d{b0 #9,pڼ8ds:ׯz9aFhg9yr$T2MhA|=;iN(\uԒpXR|M:x:~gE䍓 r v<ʩ ĒA%5h|w)m5/<>~?1$-s!~i xORI; FoQub̰X3mp[{U~Nia-cKgQRH -HrEID"pŲ/(&,?'$ Wޘ[. ;:v/@⪤UHzƞϟ=yܤ:rAg5ݨc1Vѓoov5ʛlڥ8&OHoSJZ7q~[{d3plzE::a} 3n5zfp2V0a7*eV]rymA;0_&oUVPCF^lĬYWvO92𖣋npOm~: 6?-%͎ooꎺZua/,_㼳y˧0F )|}֯R"^ʰ)Ś?ox{*:{Us|ԩtO }65lhSnXBV A*X_O.Ά"^wMmNC&z l3KqlZƆGʃZP Jb=vWk Rƶru%UŜ=ԝ 1rk*ո+'nUV5P9Y6"|w Ϲ(E6kZ)ST NڟM>鐤}&!+5j8qq̇l_!a@+k4}}SN`NnfzEm{zs$]t\vMAܼcE=i[Gsȸ9je!7oHo}y*S|Rd?}L)0dSc6_$6mk|es#=)>C\ dt59l4ɚGO^m^:SNT~`_Sن\WϧvϒYx+U(J7ʴrJwB5a./јJCc5gxz9z5zq\ۘDzioco6H^xj6ʳ/r*@-kְ]_n 5t۳ uy-<}8oWX*֐-#4;rĽ/w9C4GC-Vi+~cows;tk]u9?W1SǮCw9Laǽ(OC~Ys*y :qvRehc:|Si 2ԟg{׾5]tztYzBYfySC,CnﵗZxOħ.B &a$מ xD9NTIٓn}k_V@+6dc 2#sׄZ`ٗ6 PUa`^JST7TTK%ғ槱SlhrO½7pjQѽ=:bN@߷?ozK5|s3yvwZͱg\^U̹r҇eX͇MZ*qnp9E׷^U?atz'`.0_4I1W!ǚǾn YD=$_$`)/~cSϨ/ssH:쉷+ JԲң&'MOzT׻E8{htK{ωU}y(m]ەP:;]blҙOgqTP&Q1*b+feR9ѿWͫϒZ}'x>碨gc[M\ ̦ϟ j9=맱ݫnZw&Aj 7,f\vC!0DK*Ӟ 5krVZy۴ʆC_` 8}DɃZgzN$]#2g5;G"b5):Mw膑g\*ViXDͷvmW_^NOHݴ7/Q Ju?6J/'_Eܜ+"sO%oa7oTs.j|6FQpv?隫Z~TA*X7Jn)Z __9m4Jn]!<^sGţA9rK[xE91Wz6mگwY wHל>CDl#kiys-VI Lqj3osڮby5iS=tQ'}5Q֓bT^Pj\#kP>k%QoF&Xz;~0"|w[?laDGu-DQ y`f%,XP{?aw cgxT!jhOV%&NtɦaJm*s?(Z5s65YTPh&嚋z ]>O Mڎr+j}ZRQN'5%=q^ۅTAt׭O_|NKpILfTzuIdZ~W6l6ߦ{fcH"JCxsW.G{uH+1F~.;}Ү_oPU~pr]>*%l)sڮyV6Uؽ Îa^y]ϐ`Jg}%=ohv{?3 6ˌR/0ÊZbDP \TVQ5K@Г @hh [;_kOm'E p Ua3ZE =R\ fجݴ]vCTBO*=P.zR9fZ'$=z*nBz4o6sk{oM<&gg]f83(mzRz ?R9D5'CI:zLH:%u>ֳRgaJv|Vhn#ߺWꗈGEI?;j<sǙ1zUtVvNNܱ]2CҾHꗶgL*q;zy.κy:?\AjH$Z$u(nZ, ;#ZÝZnedN0Znnozj:"N9bHSwy~bMmakj1^?7$cCc2iaJS%fƥG<ҠY'K5_!2M˾͎}U&IX67f8΃RN Uz;RN)c,\ .:7k} WOHv<װlOn0%ꑱNӐyGxQq'l>*joH{2L5="U;0BI %KZɃDA6YڡxN%$*F5wKvJc<DX.یai3l@ЫhJ6Yi:1Fccۍ_IV`)wA 6Co'y}0d(֥&9U*UgJ㼚8- PgUMUa{Dmz@RH/}:ce<_zo٠p|4eqek}e~G_J[R̨]+kGϨ~z%͜-=s"pZz;Y>f,="Sm/j]ؽI3v/0f氆EӠYnh.rdJ@=;yPr1zx!c=Sos)Z9S2vUy ;!6o5 ? R>V_=cOH58RuT`nf aD+^MV#unN iꉤ-#^j3͒C^'q0P4'f/8Ql{0s0^%*嬂}iW3M J2/u9܆U3Ճ)E3T,RЩ_*v>y nx;0w -!Gᬇ:AZ gofwm'"  )=tQ|%qj9 rTJ^4^!rs!{ʘ=˓L/!ZQI9^n7qR*Vz9\{ eGp ]gP=k.3|?VV)@cƞ nz-RuT5.hT6{a)kL&u-|!XzЯ o)}oRs;m]?mǩ<{n}Foع?TyƿV=K 75 SNV3@ƎKpz^0gޡѬ!Ss$/Xb 9X X^^,y= :Xs?ʸй;lf:_foLy{X SN9cfksQ϶Kz(נ4skUCe2c1u F@gȉ׉Qyr ,\Ni5#-ҿI9dN+.N1#NmvX4^ItB:7bn(ɪ< xn{eOGhn0Y|0Ympet;LCqؾTHn΢f92v {'۠oPg5N[F(uD>O!Րn9o%"F,y?ḿTnLp_Bb77\2VspHbuHV̞BC zJ"q͘^t(l!;̼wwX||lB;4m5<&i¤~MrM8po k6gI)&JQ=Upah VsjA*TUzr8;]1 -)٭3G殔Ǚ |n9̜k(#sy<]l7T0:6O.\l9ڵM9;]:쓔O:Q:NA*NvXpKҒHJM!wM{儧U%SL A*XN&htGeB鬜2X2'z۽M)G6MFyA&sBΜ7- AװaOvHd)i./Uv/xrsdt_%grzFvk\X=BA8Sy`{q`l@ Y̨ɴqJOJ396mF&󷩠XSy`S6wXG: ,pw H Sp=z)`n|s20^CO*Ď bp 4@I@R v;T*"hԁr~Z;J|D!"={rHjC4$z٩%%{8?#Rt% A>60]m dw_4&z5}~=$ZR(dUɌJڵ~w AkČLK׺uf#aWղYm8e?zF3{1K,X`G*^T*H5)ze45Uߚ_!:{D%=yXyRFO|'鯫ZP*q4r{Z4wʚF6ò9(rEJO(V1tz/#zASmi#7;ϮY \wlU2??%-#]eoL0i9lj"JS>A)V=@.ٴ7z"=1ʽTwom;ғ#2U6iϝo 5tFE6=il=(~%˧/>'%@8ߓrӘ 4ť%u@w#rWFҜ6ߨHHN͆o.!hX,ͯa7HoQ:6S~E)_wŠRJTz([ _T:@V–ia)]qշ("_UDDk* pډ'T=\^)[Am+;Tߺ~1"LʚwKCb G `VM7^Gm&ղdty2,u]oe$!-2͌dn@e< X,lB2UwHǦk4JE+S1HuAE{u[`g4^ |uM|}4BUߢ(LyUE-"HUDAkR ԙΦTS˴@6U3)`\!-JU [j{}ʧns5E+uV ۞:kPiubrM8p_hۿ_IIKCefim^Q-Jw.f7i.{Xʙ'ޛpyW}ni\Dj"HѰaOv*30Gv| ;%`.kNiLΟ+S(;ǜch]WYI6Jg~ *(Lk:FEm+Nek7ܿ6d6=Lp{V꛿Uá^I\&sȑgegM7;9h~kIEst}9W|o,8.ҙΡH09JW?w.fFIu3pD2rHB%#]33)5=Q 1DVud>/k׮}l։Y^9b_c@%{8lu\i_5b5J"(̉Q٫gu'i&I—N[D%f'ֻ Db1m:h|ҙA[xsZDm6I4}31iwYeZ7oyVv7U-ӯ ,vޓgnɛ.]g{\׌Ց`r~Y1sj܊F}Z_y fTѿ&*m۳b=/ǒrϬD\krZtM'_;laarN$]#2 jw_y*][d+$LFN .\4c [D|rtM*fhtM'eKn뺷T#*Yª2 =Z]gZK`/ҋLuҾs>!"}9H_yf&3Gr_UERTT!钞  Mjcj~w:҇wOt-f6XÀU{(91Wz6mr=5wN̖JiCn5# *a4uj+khY˷(S2dKʚOڞ H<6mꑽ'ñJ׷(jL+x?=ꘒSE2HJ4!wܰs@ϲ3f۫]fϭep}9~M7LֹyvN=!an{x 4!e`S2𢱨`vX1shǎ[=$ym{d{V ;:q9Wﰁe90.<+?ۚ9(ow>eObX{l1/u\!uSVyx]nsam<ŻNkH]2)w>ﵫLu)Z=>gߺP0Y?pS?izg 7U)'5 aݭVA5^eYz brk}bL(i9sgnO&mgC!ˤ:5φ뱧=.N>9l_5zҟjRWy_?aTSuGF3<)î:Bͻd`嚫uzYiWڎCޕ:μ+z]8,rG_q%uz U*ϵZ)7BjY+]y,- 74a]uɌJ.'gBa=FpΓ>/8x+֛Aߍx3Gs^.Qޓ ezґ>_e{8WsF>f:?jl:owq R畟UVV\ Xz'S'.?Ϯc9,P Nmm.v߹ Uykp^?O#}EkX謀lp! ~n0Ǿfl498$].tC$,X@ Pa -dkgnM{Ab6S=Tw!5_Ϩ^ P˧/>'%RC5KJR NIX6{>=TgۥN gmmXtȊ +आKo׫Ws(1{ʈlw=Ta\#8+G7#TGҳXPOA-qn7ʚfգE5ȟX&gӥϭ^~?ܾݬ^'Uqu<՛NO>t͵O׿_qG>uC3Qc-w[txS]ePtP⬓*g旟iϢzu-lw/Oo^z:U]Y̋`ׅ9}psSɼIVo 4PL̳1^{I.Ϫz)w5sN*є Ֆea9[C|yKl%St{h+eV(+Fa?g-3{G>.ܾ!Ԩ9Wy /X>|*?|$˚̕ƣ|2O2MCY8 q(CV'{z ra^.tbg9T9Uu_$/ʿ_湏3aPNG pK3?_*2br]ddeY}nd9r=:ocEjV9zFɚ,?vr3L  G`n%{X[_^<*[~>vvXFWO4Б=G z1[r+TuGz篬r0kʤ&_viS=A0cL1לػɪ j./&gkV:Wǧ΀+!mj2sg}Xz涊{#}9vJlvx@@ {x75p30}l/d/dzt` b~ $ԾV]jx4G~Gj跿reƕw,=z|Luy^+n wq*s: Y;.'`_[g 6mߡ8|01sw$un]..A^S#Q;_DI^+V53#0U0\]}{l{s$Q;Ηwu_sqkeZUZqr;T`IIɟit{(E2uwnOu6¬_!If92!;HǼTtȊt46XACeTfCGc6n]A[FyJXVK 9mtl MzES=;ΗE~=EI =Y~PKpǬsݷ9;w׶.;GC=ZVyow[jXRCeݻd[y搋!rTf]lQj](ۃ2@t;#>{=ߌLF}~EoE0n`7ZR=cW~M g,{#&UAGMykeZ"/o/7J}7*'J,X@(׭O_|NK7avQC  ]I*)1שj(T 4T}/#=_EqTU>V`E\{VsVz%VZ8\^f ӥ5 }dWkuTГ C F @lR v;TA*Ď bG #H@R v;TA*Ď bG #H@R v;TA*Ď bG gH~#m?8&ZK U-7rRn%9=e.)-6@T\߸PE ~D!"R{dϞr XH\-k(6 ZO!d2l3]xZ!X}\%<,|{^W~] B9D8ZG?TZKj5II?GT.g4иS5럷գa0D^%/3'2/%6*UuzAQjq-2?.Cw&T`l"S~Z'k׮s+?3?*spD=7%y>9[/ 8k*%(k:dφMŲCeŘtJnT.W}UQԒܹ>e5+{~&O?~lundSk[lLr7VcѡVA+}6Q$_&]>9L.SՈoaEئp׏b8U|sស4],cݥ_.jaʴ׏˪瞘' QA[/ (Pma˧uufϵ_/pz GL )XG .vJWʛ~Mt:Ks<TA*éi;b!]auˈzwfJˈ8뺤:yg{mMK:^Tݻ;L WT˒}z+3ړEzbR|xD>~[>2kM\2UTSL6P*pl-0;r¹]7/cq5|*f_<ı'sjUAz>y.-Yq?Pgun>c~"}6׵v\؄=rUpfP:ۇ -Md$c헉ҋ 2|r Ht-"Tjؼp{f;s\\\g+VCɜWܩb e8GfJ6KA5&Okjhvf{SM 5d1;rP7F'1H5лzZ-6 uO,R6n0+l0q9SATE}6݈2Vjo[itPʭ{8vŵMTyp feɼ!? WwlU7\?ٍ%bdy|'\p׳yKWwo.( @U=4 yGۡ)d rnM2>b){?_F䭞cYyDE`nD*Jԝ2˸@T9Yg< sn5\G Ʋzvg);2!ƽ/a?rTOK:Q 'uwKfǶj>2}fXWV>Y'~;,5ܟ\e-5fK/+ΑD ыbDʐɨ@LLIi쓾eOj ٧z-ZkMu+J% lZysӵQϼb.)iuX 2<#g'`XM=~!1xqmQѰ6Ky*Ng+mB`-~?U21*>a6^Q `d%`yĵIa5P^]~S@%4йeY[`.݇v#XzS*urn>"O|SClz=b ]?.qj9j@-w>*ɆFCzCT*ڿ#ȥ",A J5%$j*xxH}e:@eTMk V*=~9ggbXQ՛jo&ٴi&Z4I~-{aBSCᨾkY|]43\:{)Rmc7erBGٍoǶM*|}?*9uUٽr9j>2ue+(]z]-rgnf ǩ< #zuNdl 1n|_QCezݒ9OZͅCe(3 ezDepg.63cWek}cKjȃs8a69&koRgL~G*=LX4`o\'p=sU!j,`q}j/ԭ{~"=/ {p@Gm4{Rdiֽ+s6=Oy?fwt(?KX~VL:jOei͞o*%_cW%q֝St?;XMqAA4:ZyP@-TCQ}USKnȿpAd:;`sD*2{;O@!]tjN+:؟ Uwlm^W;ƫr>A*@R#U* F|ˡwQE.=9,{OPYF͗{칀7j=5%s,suݠkNp_KTM- kÜh%oCU-m*ܱX#s4zTejnJ3Qh-j=엑o_m.B*U+u+vU }᛻$Yu_T^<(XJ-5嘖:`r{[1KixXn_}+ûE>O3 T'ˬ*pq=S*dE}@Sj{n0֥U-RrwJMB]ŕ 1!Vd]gZG%}7<= GoS<=Њ(Z˺7ͻ3FUnVܜ"HPz>qt ?󘉺SkbσeW^r=s:3&Cz^$k&;/#i-Sf00UFz5`}*sdEDAOYՙYR.+U""ך9Ik#jka CC䶹VZxnŹrdPJr~V9W#Ca6-9{4 b6NA{AK̟sY'7DU@a[)LS!o~xlMu海zV>j<xR>o%=̃beW^t;P K @lNڟ9gXUʾjϑ A"/-_zEwRs8ZJEޭpJ&Rvw[K{"aeȜ+o m(Zk7r@l/~3 <,Mݞc%9ts$oV֜=*O}G䪯J+ӫ#mP=[~aX-W:s Jɩ|}[WnO\C-YaLZ| WV8q.o 5|_|cU'S"q,n?&WډOm3]BG=Nni)X\~(:<: `~r Wܔ:z Wwwߡz!i_.Л ̤yR=6y}}}h6Oǔ~= `cg_v}٠'V Hŷ&yF!B^PIُ=Wmkog={e՚_ u_u ApOmR=3U$@AE!ø`zzVea/ې^J\ۄ˝[FL::LKaɵ~sS=ʹ7<~Q>ꅞ(&=׍~ik!{{3|zTqj}'rV}矉^u dI,VBOva>UYZצf{2q_?aqY5VGj dkLg9G'RJ*5Lޢz摲K=L_S)i~LU>>'6,\15-SjϛPzSGO"鴤'Gw>ў?im6ZўM>ۨcMHtQx2QlD&+ަ8?,(_i+87ԯ烪'PZQ);ad*ezUKk5<^ 9{\=6ŵMDe䍗f9z2ߝF;@ NPQ|2瘑,˸"Ris3S8uyOOsu[PvZNuWz|f.ߪLO O`>QhJ?<>ۦ)&l|KHeXـ5o֎mҾH=isQ9z*Nڥ_MfJbUn.UҨ'&O,yX`*8(oq86@!^\sճ7AP_\fJ\RQGQzd`$ Y0$lʹ <99k6INm J%v/(Îq#O@j>H!9!%K/K~|Fo+{wW6t{ޯԫKdIR 4]40ytxˮSj:3/r[YI\`ZgMs<{>C$ ~1'}s1P ٧gE{~KI:vY6M= ` =XdO>TxT6Xq>搆}T~27bSO`B 2B﫡RK/d 0*Ps=vR}~zvZ^;a_.>Ԃc0qcHp-+oz,7ԡ閿m柖\^t^ =?]z]* A9ص:;T+ʖbsK%o-X/=k}o\k~ ؛ȭKdcʎ8ay>*xT$;]zufȓVrLyJS3yQdSd*t"/XrΧ\A(yco_RF҆+~vPAårvImxqΏuz@LQQv߼3Թd@Ht k@y ſ| Qu[| J?/-+PA\Wv;bkj)ߋQD.yYv}xg۷..0Qy(T,U2pND^t>~7gԵ:7_TFΟXAyR<_|YFqȓ̼0gr}Wu\iL.j`+oet-I LrzK\v*\3oɛzW柾&^W= gS~c WF:zC]**?6*fPk\3>#sS{T =aon;&i*i S>*G *z_HIө_QxWjH^}4쵹`Wy>~A-L9,ٷN˹/>^T:q_a+e{=yq.#_\"/#ظ%ٞ[ف\L?{ |ퟬ|ɯuչ}}dϻ{S%կYCRyHK2T PL;[~U2MAz)9?~sEͼir+$TUЁqb tڕf5WCPA*0y{Af;YwLN~/5g4MK+, jH6/%~_۬֔*߿$MG!B2{n ʣ{A9\(>Tay=)Mf)'ݫ*2٘ݿ X}$E z:z9^kj?|TvɦMv-6ApݛLיWO";0AKzT^pX6=&_++?l`_.W'G$iNa>WyK3ԟ6Nșl(n 6ņ+V(+=*#xs:){vOCP28*rч}o rՓeBۘCY%kr\fJ~ߔ A4_|buZu|dޔygf0ߺWXqce7Xywwj@jYQX.xR'z=ywd?|ufHVc\wDs;(O1CPĂ I5b()<ՠ<&#;U<歟1{Q |^X c_u 6rCFpMCWNȧK-*fi3yyDJf?{?>0dϭϖOZQ|R>mbn*sYugyȳ:уG#HV>5AOzMUrޭڦ|JX]9/j:Hl'Rg/~)P;TPsJ>S`l RkOw)\%gA*%nujU9AԯfgnPv7wTO;'qԼ4; `;HUUw:/tHXv6D{8/l?$W˧n^u_w |"~ &d\#=S_LhjLF2ѽR/s=L OޑsS*i2?.CwTdlϢw"ll[2~˖II9QyT0׿(G?*[7=Oђ垇-㖚Өzۭz^ =Թd{:Β6ٶ̪o\4 ҶQ~HmOGzocj5ɻeJUX9n.7i{|5-7 GMow9(s=ezBU^0e7y__>Hy~9h9;DZ=fe#(Cc>>\KyomWE/?P+>E_x zM%c{WIjg^-PVn&ACc2M>yōʹ?T'mLMGb\q׀ ( k7L=ۤm-{(?Crcv֏$,˿ rnBzo1d>^[/w|QI񯍺rHJ[=bY 7%W=?\#/wK륙˛C} FݳŬc~y 'Un>=T8&}X/)J6]Y~jZv}zqүU N-UE u3&~k0#mBzt+~wTz;z/"5|^W"#?"Cyʠ1޿O9|oߕ}_MHj; u)PSҺcp**)|Wv=/\ӃQCaetRa;/Z剷~kaSןzd8*WjI]_(ɂ*Wow"i.iʻ.Yf8dzRܼZt<a\Ȫ{IHc=? q~wtS>4!JXm{|~y}r_Im?.& x%أ/omzKh=Wr=:ek'~`oÿɝt_J>%qc.S~?i+xCҙgeBL|Һ]hԡG^Kә ye+<Ex4x IJoz1e 'Ie*8ufY!%[ Gy`'?˷wu;; nW D_`my@/Q0;ŝOV/Lz"Oz|T^_q9e=1ߏ[\ y6&/PYWJg~./I zÈlܽO?_'f.woyշt fJƞ~|ɮ;wWˮ*S&eK:Ǯ۲ݯ@ 5z Bq}9b@C}$o㋋xq1p궊50z}+Lz"`R/WQ'_qjs/ ?}vwǹίg:w>9|s[2$xzOz|D>Wy);?V sk\?oN;1o@'~T١llH>5&Tjy~`ySn FLQsa~Rcy6HvT!N5zpZ:dA߾EښHbU~czJdr>k ydC,_ 8LҽLS25]T&}lA`Qs9u7P4-)yi6e,c\!Ow6 U54\ȱ30LB~c>)Uɷw t%Kww$}(oS1_&qg!wj6IOg˧`gfY6%?~n8MV$wc_z;ɱGbG)~c cqqR?]tgUR>G[:dC2|1TdQa4$s=43B.[7%,+Sz!( 8#JuG]W9z\h" Wgg*35LkY|gx(ENɰ=O Ӳ~oomoj 1Wo~{r@:>} I eS<1<.vrvF^6r A*Fv7D~ף)=, 3ֆt_;~VoQښ\~$4*^>%\|Mހ VK=?UOZKf:XUVT 1q| 1\ЂۙA쐀eEOvQۯW^qooq{T.oB==q]\e'Lf>6UY1vٶSυ5-e"rЫwP^z]:D=2xF8oQwOo?xWyGR՘sǾS]|梨.y==6[a`L>"'uߎ<[Vs!6ӏJG{;!uI6w{PVzG!-Flz51O@=Ҷ|l{ٿLV,umʣvOc)A_̀+Ny;rz}Cy@v.7oGޢrBQgı-2>>}kY re+M{ܽhZUkBZ=O Ǔoos񛳅̷7zͷTV)̻ߗzd]֓XL} }tlL*\@$Uz~nx%)zBWȅA>&KZ%ZݽrcN+6<*=+ v pygjGhh Ӄ1+G+9znl{!w[vlU:Rrނ\ގ}o@^ҫ}xQٸ1'|o)iOH=/.e0W+{dcc._G/jy=ҩ<ݗ~]G/h٘+/?ܔ?/r*)RAz&/usWKzZ*":7}PNzX1OTϲ@CQgB]xr=VOo=hb6Pf|ozR\BY?/m?J7|r#zp#'ZD J[>`|꺟|Cqi Bc?TݼIfXq (T(R\ @R v;TA*Ď bG #H@R v;TA*Ď bG #H@R v;TA*Ď bG #H@RaNK$"?ُ{ԄCn f JxOIbL׵h6<_3|zMblBzztkAX}߼Vz|:*u}u GfԟL<也ߜYUZ۸>I uwIvӯ#suF#yG?;*Кo7w,0q?gN{^ :H yT"p[d~.W&3@d/_ ُG̬i?,ҾH/2I}\ ̪_P¢ziRo|կ^[Q5h91}K1%S^e(';doO ԺIP簽X=Δ9l)e,Ky;ϱ{Zx7Q"oCְYʇK*&6w}HEXИ^^ʘ+?̧yJu}1]O&i+Z}Ň뀵us0/P_ސjao%C{ev>*~q֕2>:|;E{v3ϭfi+#-v`J)ϽĽWWt} >U^:GזFHZᢷwCw^v>j*QLowD9?Cfdx*>$m(z/7#FZ;5QrǨ+/!nH:\CɻxDF~>9~{?t]\gVPfGO&IrIsтmraPjI֩W$yO Ȫ{VWL\UG8j}'ҠמEZ K ص~s4\e:_PyojE~<#w45=0n,I#a(:9S'[{s11hBj0ڥU 6LN:$÷aumc+*Ѐt<+V1뙦)x},#نB76Gc]93(lW Ӟ͟ytg21uҍzOla sfxX~Lj;f3 ;Ts(uMh~RVnu}; uȮf52-SǍ=fp t׈fxykk3]2=PrEHowTs9O!H췂 yW*}MD,ϧlB}RY=]=d1͊ 76?wtH>$^"8}Px\DcO<.v*TPjH6nC]& zjQA>+?)MN{Gy/Y'#^uRL?5ʻUk"OZ[b}6^Ny>;|*Z>a;>t}ce/ȩws~z~Y (p^A&}[Jȼ{yOx7ΡByPʃZ/ĵ3ؒ~b~_>0etժʚj#Hdփy8="WO?.~u,F7_YV ~Ju*jwTjZ=Pق4Q3\s9pecsIRq7F},)CŨ[熥vKΪ TxeVp[TxY= ;q!0K܀`Vowc @_9BϜUk ʿĵ]=oj'U}$KrZ9i {x}x%'Qr$?8(6KAI&; ?KzTUQ(lQSf,=;s18aSj#z';Ԗ^7/qp ٘VSPOyP"OOnf*qդ{e^Wp½l*UCj?_'yfjv]En/4oL \zߪq^56;/ZGj{f{G?w~Wgny@9RD-tOkLf`O2E%5`-fÿٻ(06Y((kRD68pe9>BU| .5W0Ae>C7gJL Peu믞}I@9J"Q1տzv5y@d YT/jg i+vըh61ҿvz}Jqy[λו뚨t aV^TnV|Nn)S@KIu2(m 7%|EI~zB^vDL<{\`cM L 9~ `낺pOdSeU6ϧלG7D_X[֩=IɄs\ltp3]viX {B:^sVs2g~sLINXډ˟<+Dgy|bx<˖nuIݯhcV0Mfz*)Ļ@ӏiȟKfURw̲7{JbR_+sOptSױN3 owêGVPʽ}3ȽlqSd>,+=(J]Wr揣 Ua4k̝8[tJytU=M42IT_ HxUUuV8|3u=̨ᢷwü65/ReyU>V7&C59@2y6gԫ yu䚨9gH<'}b|plX`AqT(ΐyig~U2v_wv ۏɪ/9Efb* ۨ9}[ޑ.\};#卻nSrnj7ń%|gnR(=ŎSwg^)J&J/S ׭O_|NK0MRF~j'7Z*?叻wmz$N T_?-;m.2}xOfU Be8(yz5,oySlHBCe)0|E0pNcS:g8}j\{Ry R.Xyd9Bpse{XXZΩ N=q{5;rvsUz*mcx+RyHzTIQ@L9qm={'/;-ܩpj4_?{S)c5{RГ 90w@R v;TA*Ď cep}BMM?&D=L@- UKj5II&B@e2ҳv=<\ɜ3g ="s-=sNHo"!3s{HcL/wpzUTuișAiK4KX6ܞ4c ϧX7 `u0t*ђF+2엮u/Gf2:7gƺu]2o"sblr|Wu{'=e RVpgk-| &ԟe<&zD7;PVy{:&gJ'iθ9I%cڇZgY$AiIt& ]?ػcm8E)0r,%-#]eoyP9Y1߂3M}e[z|*q߱ŕa?֝8ͻz],g}yHgcv;ga)aS(eZSm P{Z [-˧/>'%aY#iG@FKFlJ;=$*i>O϶K:Ճ)پBuPw <۩UtȊtԫ\ ^0t#c/5wNig1 MRdp(jGtVCH RE7Ů? 2_~2*Ӣk~V2-|i՝JOP^ b!hH|.Mz5=ŏU8È+š%q[.p2oQDQ˧ر]i\Uj<<bNpO|׾Ng6jZ7Jt5FaDQ!;Q s;܌#i2Ys ^x_w"'(e~".6S) /mկe~jsGzEU::SA_j:H:~5'}˞iirk/2i1h|t9yMqeP pR)ԉjt\d)_nSu[Y8 \ײؖR񼎳LϦ<(e)=&&'Ɣ0yP(n6 ycakm7"x;QSV=8;aUglcg}Vs%2"v!u<(SEQ?k.YvF.5T;E)aUR2Zwʪ;EN_aܙMe{X<7$cud9Uu|J0!mO^Ze8_2SAj:H_~lZ+]fj-5ٷ_P`鳧lSZz[,:T Xĕו:ޞMiӦ{!LzZ-3[+ؕMOhٳ<ԏeka\as59ls`ϞlgIsShYqFYٙ(^GFTUsM]ʴZW)6—iyP9rFwt\+zJ ׳UAeu0xYR9p뱿DQeq'"={7JΜsi\y=ʴVͷ7Hņq1ӣ۞ܜH&ee{`O`/[}'rgOpņ*JѰۨLσju8;Nzw ZnY'fd|GJs3j:"Ng{^\'-Czfվۦ&ΌKc^d tW$@@F҃"sCVLdd 5=_!&HeSLIpy%HGhM-Dzqˍ?r_v<yIDATSlhد(;sLks7D9{tzQ؟js2mNBعtU6@Cd5umGF03[jLN1USwK8r+('JxrH I{xz;{}c/*k?RݝC}{e N>|f]ʦ#wed9ѬcSϨeZWzrcH:$ g)5~ ,$5֯(_`j\y=ʴ4LON}֨e9QyWO\#[.В3DI,ثڐ\;Qsnt?ɚ Rg}WmuL̓*/N9s%fYM sma lSJ+s[ {Vv9T2;$XyNkY^Q˴z]{b*-8N1;\n aVbwn|>7̰75LAģlS_[+ғe; Pc#H}aRKji0+jwqӇQuN5?s/W.S-LDJOvrkM{Xs /-iys-J@MֽUOPzatddu`v+%QD=lvd\㪝ׅQ MNHe᳾ 5-BF;;˹/`}:UXz?om?6GerX!M{OWI}*tf1)zek8SyE7K-h)-OCls:]w1A[Sp}IQ4r QJW-|(6!blT[/ <ʞTtaD|dFwser_m<\Xq>d#?<UF{Ԑzyy6KwIW?hZu'dcE:q՝Z.S[su6mS܂~=N~w7.F7{0CLJIc̅IåǨI\,*̹AgWѽeԷju:JsߩvTs{4J Y_ywZL#םqb}@ٞT@hh [;JbzR\?^U fT(׬=M{ PP!ѓ 'bG #H@RD#բ}fE*h {L%ֆ0ְa K4l=f.%-:6d^^nzTD&3*=kZ3LdNșKZΞO HQI"HT)wӵ֓~Z.4=>=5gTC= PÕ-VR{Usw\͞T4ЀNjE 瓯"'2RQ򭦯X`LwݷF6ٿ%u@:u|i,{ODOvsSsR)U%-ҽS٫%Jz(S(r&]:=EIov_+MsݻhiQ0_^>}9xF/™s=?vdWvu]2VKf3ۓFH 7CgQHtu[vSe 1'jԭMXzXu뺤( 8ΤZl:.QmV\ yf{R99{AdItɦ'RKgMz9lϣ~Y3ZT@auҵIߡ߂+ӫȾ*!JzOOoE&nծq_ zR\s'jIN˜2QyF# jǩY bF['=gBca]Sxgd, !Jk0/̩TAz]EsS< л'x>SI9<Ɋ cǷGP|+M"fkRnOIsUE=.E=+u9+5[srWs{_G_I)E=z}=WsJ%aWFJAb(j1Huoi]hA͇3уTʧuަ o3q-_SmBWfdNٴvꗑIkξj36'=9`zD6?)'ʬgjSug Y3ܟ_&ӥ'Y+]^5-B`"̹#I |2Q]%zzY =~cnf8^6Eɷ5=@'5kzR` H@R v;TA*Ď bG #H@R v;TA*Ď bG #H@R v;T`fDB%Kn{i ~-w^%LH#-bCRx iSd`{4/QzN/lG cLR*Ng$<~Rגc)~>9K糎4@:Y=Ӣ_:3{on|Le7= Xɞ>|sXzFdRo2x( QCDsa I5-}y<[& 1I5kP[A3/ryOj-$SJD "uwz9yl3,'Bc2wܸCOYo(W,k1yˎM%c{SѴ\nքViSOoU25Iϫ/IWpdpK^ ˣBk*܄ަ^}y]WV^,o0"֏(KZze@0TvomԨ~}s7禣L[eGҳ\(2=(Z6FiHgZo5[Oz켸 C-+RX@P@ KrCe_"7@|詍v%yct^DJ7Z ǧzu_~BJ2x TP|q [v$wήB}?#IVog;e~Ge< ǿO_(?>-M ?$/^{KEְ_8Til&+VvKZ1B^6J΍W-E)M'Ȼkډ]q]*;e˥r9l}J̝'+9aWgj}O[y чn ([cSᢍCme;y }{Xzׯ+Pxnߝ:*c%e_ wܦO[ P.XKƿl\\=\MȄ=%d|YcC Mr8%S]tt:#q'_:˗ȅK'{ePZ5U{8@}[V FzWrc=o@,6cS! x^yUQz:稤vwug-0!.*4A!Gڋ%D0avc.ԁKףW/B.&W`Y.,F\+&۪g{rNY۽oߟsΛsn,lg̙8RLUhhwi) kX u5^ےښm1R\:=^鉃9 uTwGr_e1fV?{*>yt}~0YU_k9SoُqB+q5 G@>:=uV~GuVSvЋ'Ⓟ3q塕*¹dpT:sc[WB冰kCnZ8:]偝1\,B -ԃF@> 1DOtdczC?鈎YV1U{5_h庱xou-ZK1T&2+h=vѧ*4$ց迷ԯ1PԽVFMR &M|%PZמ7jVc{ywuĞc]u4mс#/JX߾%s-h}}_> ^Z9kÙ89LJY"R7bZ;<Ҏ6ݿlpn&aVɛPu[mGJ%W+T{GcB2YO Iȴx8Gf+aTc⯻dipc88WhɉZ@yB*`}ے84AyT;{s|9yMY1"NW^}$rB*sܹرcGd2ʽ<&'Rq@D˱X_WLĮT+nFL2Y^1UB*300Ne\7.=;qU 'Pܳ)jQjmDys@H܆jU[<{rۿ1ћlTmgɨ7o_nW;نGB*sȑ`|/o\1.$bDo&q%]3*T%c obr/Ͽ|RLւO㡑ѽ4m~'ĕ(U+^/bZTו 1uorTJkvqq2\Y_lJ{'P}k4771גX # # SPDX-License-Identifier: CC0-1.0 # # This file is part of the program "Hyperorg". Unlike the program, this file is # licensed under Creative Commons Zero v1 (CC0-1.0). See folder LICENSES or go # to hyperorg/pyproject.toml000066400000000000000000000067521522421240700156420ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: CC0-1.0 # # This file is released under Creative Commons Zero 1.0 (CC0-1.0) and part of # the Python Distribution Package "buhtzology" which as a whole is released # under GNU General Public License v3 (GPLv3). # See LICENSES directory or go to # and . [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "hyperorg" version = "0.2.0" description = """ Converts Org-mode and Org-roam files into HTML for serverless local use.""" readme = "README.md" requires-python = ">=3.11" keywords = ["emacs", "org", "orgmode", "orgroam", "html", "css", "zettelkasten"] license = "GPL-3.0-only" classifiers = [ "Programming Language :: Python", "Environment :: Console", "Intended Audience :: Education", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "Intended Audience :: Other Audience", "Operating System :: OS Independent", "Topic :: Education", "Topic :: Office/Business", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Information Analysis", "Topic :: Sociology", "Topic :: Text Editors :: Emacs", "Topic :: Text Processing :: Markup", "Topic :: Text Processing :: Markup :: HTML", "Topic :: Utilities" ] authors = [ {name = "Christian Buhtz", email = "c.buhtz@posteo.jp"} ] dependencies = [ "orgparse", "python-dateutil", "packaging", ] [project.optional-dependencies] develop = [ "pytest", # Not mandatory "pyfakefs >= 5.7.0", # Fake filesystems for unit testing "flake8", "ruff >= 0.6.0", # PEP8 and some more checks (via unittest) "pylint >= 3.3.0", # Find code smells and common bad coding habit "pygments", # Some unittests not working without "codespell", # common typos "reuse >= 5.0.2", # REUSE specification ] [project.urls] homepage = "https://codeberg.org/buhtz/hyperorg" repository = "https://codeberg.org/buhtz/hyperorg" changelog = "https://codeberg.org/buhtz/hyperorg/src/CHANGELOG.md" [project.scripts] hyperorg = "hyperorg.__main__:main" [tool.setuptools.package-dir] hyperorg = "src/hyperorg" [tool.ruff] # Ruff counting branches different from PyLint. # See: pylint.max-branches=13 [tool.codespell] skip = [ "pyproject.toml", ".git", "__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache", "build", "*.egg-info", "docs", "#*.py#", ".venv", "venv", "*.html", "pygments.css", ] enable-colors = true # Print N lines of surrounding context context = 1 # Check hidden files also check-hidden = true # Dictionaries to use (default: "clear,rare"). Current: all. builtin = [ "clear", "rare", "informal", "usage", "code", "names", "en-GB_to_en-US", ] # Print number of errors as last line on stderr count = true # Simulate "# noqa" and ignore all lines with # "# codespell-ignore" at the end. # Credits: https://github.com/codespell-project/codespell/ # issues/1212#issuecomment-1721152455 ignore-regex = ".*# codespell-ignore$" # Allowed (ignored) words ignore-words-list = ["assertIn"] # Allowed (ignored) words in URLs and URIs uri-ignore-words-list = ["master"] hyperorg/src/000077500000000000000000000000001522421240700135035ustar00rootroot00000000000000hyperorg/src/hyperorg/000077500000000000000000000000001522421240700153425ustar00rootroot00000000000000hyperorg/src/hyperorg/__init__.py000066400000000000000000000213341522421240700174560ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under # GNU General Public License v3 (GPLv3). See LICENSES directory # or go to . """The Hyperorg package. See README.md for further details. """ import os import itertools import shutil import logging import pathlib import re from collections import Counter from importlib.metadata import metadata def _package_metadata_as_dict(exclude_keys: list = None) -> dict: """Get package metadata and return it as a dict.""" if not exclude_keys: exclude_keys = ['Classifier'] mdata = metadata(__name__) result = {} for key, count in Counter(mdata.keys()).items(): if key in exclude_keys: continue # single value key if count == 1: result[key] = mdata[key] continue # multi value key result[key] = [] for val in mdata.get_all(key): result[key].append(val) # postprocess project URLs urls = result['Project-URL'] result['Project-URL'] = {} for entry in urls: key, val = entry.split(',') result['Project-URL'][key.strip()] = val.strip() # post process license data try: result['License'] = result['License-Expression'] except KeyError: result['License'] = '(See LICENSES directory)' return result meta = _package_metadata_as_dict() """Package meta data""" __version__ = meta['Version'] """The full version""" __version_major_minor_patch__ = '.'.join(__version__.split('.')[:3]) """Version up to the patch level, without pre-release and git identifier""" def get_full_application_string(name_as_url: bool = False) -> str: """Build a string representing the application name and its version. If a git repo is present information about its current state are added also. """ if name_as_url: url = meta['Project-URL']['homepage'] name = f'{__name__}' else: name = __name__ return f'{name} {__version__}{get_git_string()}' def get_git_string() -> str: """Build a string containing the applications git related information.""" try: git = get_git_repository_info() return f' (branch: {git["branch"]} at {git["hash"]})' except FileNotFoundError: return '' def get_git_repository_info(hash_length: int = 8) -> dict: """Return the current branch and last commit hash. A special case is when that script runs on a Read The Docs instance. In that case the branch name is extracted from environment variables. Credits: https://stackoverflow.com/a/51224861/4865723 """ git_folder = pathlib.Path.cwd() / '.git' result = {} # If run on a Read The Docs instance if 'READTHEDOCS' in os.environ: if os.environ['READTHEDOCS_VERSION_TYPE'] == 'branch': result['branch'] = os.environ['READTHEDOCS_VERSION'] else: # branch name with (git_folder / 'HEAD').open('r') as handle: result['branch'] = '/'.join(handle.read().split('/')[2:]).strip() # commit hash with (git_folder / 'refs' / 'heads' / result['branch']) \ .open('r') as handle: result['hash'] = handle.read().strip() if hash_length is not None: result['hash'] = result['hash'][:hash_length] return result _RE_SIMPLIFY_STRING = re.compile(r'[^\w-]') """Used in `simplify_string()`.""" def simplify_string(string: str) -> str: """Simplify the string. While word characters and `-` are kept the rest is replaced by underscore `_`. This can be used for filenames. Args: string: The string to simplify. Returns: The simplified string. """ return _RE_SIMPLIFY_STRING.sub('_', string) # default log format # see: https://stackoverflow.com/q/7771912/4865723 _LOG_FORMAT_DEFAULT = '%(levelname)s : %(message)s' _LOG_FORMAT_DEBUG = '[%(levelname)s] ' \ '%(filename)s:%(funcName)s() ► %(message)s' _LOG_FORMAT_DEFAULT_CTX = '%(levelname)s : {context} : %(message)s' _LOG_FORMAT_DEBUG_CTX = '[%(levelname)s] ' \ '%(filename)s:%(funcName)s() : {context} ' \ '► %(message)s' def init_logging(info: bool, debug: bool): """Configure logging. Parameters: info (bool): Set 'True' for a more verbose logging. debug (bool): Set 'True' for full/debug logging. """ log_format = _LOG_FORMAT_DEFAULT # log level if debug: log_level = logging.DEBUG log_format = _LOG_FORMAT_DEBUG elif info: log_level = logging.INFO else: log_level = logging.WARNING # python default logging.basicConfig(level=log_level, format=log_format) class LoggingContext: """A context manager temporary modifying the current logging format with adding a separate string giving information about the current context. A context can be a specific org file that is currently parsed. Dev note: It seems that `pytest` and `unittest` do somehow hack the `logging` module and also ignoring `basicConfig()` and `setFormater()`. Because of that there is no known way to test this behavior. """ def __init__(self, context: str): # Save the original formatter self.org_formatter = logging.root.handlers[0].formatter # Calculate maximum with of the context string based on # terminal size max_length = int(shutil.get_terminal_size().columns / 3) half_length = int(max_length / 2) if len(context) > max_length: context = context[:half_length] + '…' + context[-half_length:] self.context = context """String describing the context.""" def __enter__(self): """Set a new Formatter with formatter string using context""" if logging.root.level == logging.DEBUG: node_formatter = logging.Formatter( _LOG_FORMAT_DEBUG_CTX.format(context=self.context)) else: node_formatter = logging.Formatter( _LOG_FORMAT_DEFAULT_CTX.format(context=self.context)) logging.root.handlers[0].setFormatter(node_formatter) def __exit__(self, *args): # Reset to the original formatter logging.root.handlers[0].setFormatter(self.org_formatter) def collect_minimal_diagnostics(): """Collect minimal information about the application, dependencies and the operating system. Returns: dict: A nested dictionary. """ return { 'Vers': get_full_application_string(), # Workaround because orgparse does not provide its own version. # See: https://github.com/karlicoss/orgparse/issues/68 'OrgParse': metadata('orgparse')['Version'], 'OS': _get_os_release(), } def _get_os_release(): """Try to get the name and version of the operating system used. First it extract infos from the file ``/etc/os-release``. Because not all GNU Linux distributions follow the standards it will also look for alternative release files (pattern: ``/etc/*release``). See http://linuxmafia.com/faq/Admin/release-files.html for examples. Returns: A string with the name of the operating system, e.g. "Debian GNU/Linux 11 (bullseye)" or a dictionary if alternative release files where found. """ def _get_pretty_name_or_content(fp): """Return value of PRETTY_NAME from a release file or return the whole file content.""" try: # Read content from file with fp.open('r') as handle: content = handle.read().strip() except FileNotFoundError: return f'({fp.name} file not found)' try: # Extract the pretty name return re.findall('PRETTY_NAME=\"(.*)\"', content)[0] except IndexError: # Return full content when no PRETTY_NAME was found return content etc_path = pathlib.Path('/etc') os_files = list(filter(lambda p: p.is_file(), itertools.chain( etc_path.glob('*release*'), etc_path.glob('*version*')) )) # "os-release" is standard and should be on top of the list fp_osrelease = etc_path / 'os-release' try: os_files.remove(fp_osrelease) except ValueError: pass else: os_files = [fp_osrelease] + os_files # each release/version file found # osrelease = {str(fp): _get_pretty_name_or_content(fp) for fp in os_files} osrelease = ' | '.join(_get_pretty_name_or_content(fp) for fp in os_files) return osrelease hyperorg/src/hyperorg/__main__.py000066400000000000000000000105371522421240700174420ustar00rootroot00000000000000#!/usr/bin/env python3 # SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Entry point for the package""" import sys import pathlib import logging import locale import argparse import webbrowser import hyperorg from hyperorg.exporter import Exporter from hyperorg.reader import Reader _log = logging.getLogger(f'hyperog.{__name__}') def logo_with_version(): """Build ASCII-Art application name with version and git info. The use font name is 'Straight'.""" ver = hyperorg.__version__ git = hyperorg.get_git_string() logo = [ r' |__| _ _ _ _ _ _', rf' | | \/ |_) (- | (_) | (_) Version: {ver}', rf' / | _/ {git}' ] return '\n'.join(logo) def _extract_copyright() -> str: """Extract the SPDX copyright info from current file.""" with pathlib.Path(__file__).open('r', encoding='utf-8') as handle: for line in handle: if 'SPDX-FileCopyrightText' in line: return line[line.index(':')+1:].strip() return '(ERROR unknown copyright)' def init_argparse(): """Define and parse commandline arguments. Defines the commandline arguments and options and parse the command line arguments. Returns: argparse.Namespaces: The parsed arguments in a dict like object. """ app_full = hyperorg.get_full_application_string() author = hyperorg.meta['Author-email'] prjurl = hyperorg.meta['Project-URL']['homepage'] changelog = hyperorg.meta['Project-URL']['changelog'] gpl = hyperorg.meta['License'] copy = _extract_copyright() # REUSE-IgnoreStart epilog = f'{logo_with_version()}\n\n Author : {author}' \ f'\n Project : {prjurl}\nChangelog : {changelog}\n' \ f' License : {gpl}\nCopyright : {copy}' # REUSE-IgnoreEnd parser = argparse.ArgumentParser( prog=app_full.split(' ', maxsplit=1)[0], description=hyperorg.meta['Summary'], epilog=epilog, formatter_class=argparse.RawTextHelpFormatter) parser.add_argument('inputdir', type=pathlib.Path, help='Source directory with org-files.') parser.add_argument('outputdir', type=pathlib.Path, help='Destination directory for html-files.') parser.add_argument('--hardlinks', action='store_true', help='Use hardlinks instead of symlinks for images ' 'and other attachments in the input directory.') parser.add_argument('-s', '--show', action='store_true', help='Open result in default browser.') parser.add_argument('-v', '--verbose', action='store_true', help='Give detailed information.') parser.add_argument('-d', '--debug', action='store_true', help='Debug output.') parser.add_argument('--version', action='version', version=app_full) return parser.parse_args() def main(): """Main entry point""" # The interaction between locale and datetime seems a bit wired on the # first sight. As stated in the docu datetime use locale but the local # package not the OS locale. # The locale package itself doesn't use the OS locale for datetime things # by default (because of unittests). # This need to be activated explicit. locale.setlocale(locale.LC_TIME, '') args = init_argparse() hyperorg.init_logging(info=args.verbose, debug=args.debug) if args.debug is True: # Name, Version, As Root, OS for key, val in hyperorg.collect_minimal_diagnostics().items(): _log.debug(f'{key}: {val}') reader = Reader(args.inputdir) node_count = reader.read_org_files() if node_count == 0: _log.warning('No nodes read. Stop here.') sys.exit(1) exporter = Exporter(args.outputdir, use_hardlinks=args.hardlinks) exporter.export_to_html() # Final message fp_indexhtml = pathlib.Path(args.outputdir) / 'index.html' if args.show: webbrowser.open_new_tab(str(fp_indexhtml)) else: _log.info(f'To see the result open {fp_indexhtml}.') if __name__ == '__main__': main() hyperorg/src/hyperorg/content.py000066400000000000000000000217311522421240700173720ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under # GNU General Public License v3 (GPLv3). See LICENSES directory # or go to . """The content module offering the Node class.""" from __future__ import annotations import logging from collections import defaultdict from collections.abc import Hashable from typing import Iterable, Dict from hyperorg import elements from hyperorg import textruns from hyperorg import parser _log = logging.getLogger(__name__) class Node: """Org-file body in a generalized and strcutured format. The body of an org-file with all its elements (paragraphs, lists, ...) and their structure is represented by an object of that clase. On base of one of its objects it can easiely transformed to another file-format (e.g. HTML). An object Content is the input for the Exporter class. The Org syntax reference can be found at https://orgmode.org/worg/dev/org-syntax.html. It is not an official document but there is not a more official. I was pointed to that document asking on the mailinglist. Development note: A node (with "d") in OrgRoam is an entity with an ID. A note (with "t") is something more broad. Escaping HTML entities (e.g. "&" into "&") is done here in that class (see Issue #47). This is not a clear solution because HTML related operations should be done in `Exporter`. The `Content` class should be independent from output format. In consequence the current solution is not for ever and will be refactored once. """ input_dir = None """Input directory where the Org-roam content was read from""" nodes: dict = {} """List of all Org-roam nodes in `Content` format""" heading_ids: dict = {} """Org-IDs of headings (subtrees)""" IDX_ALL = 'all' IDX_FILETAG = 'filetag' IDX_NO_TAG = '(no tags)' index_data: dict = { IDX_ALL: defaultdict(list), IDX_FILETAG: defaultdict(lambda: defaultdict(list)) } """Nested dictionary containing glossary like data.""" META_FIELDS_INHERIT = ['date', 'filetags'] """See `inherit_meta_fields_from()`. """ __slots__ = ( '_orgids_linking', 'filename', '_meta', 'title_org', 'title_runs', '_elements', # '_e', ) def __init__(self, body_lines: Iterable[str], filename: str, title_aliases: Iterable[str] = None, anchor_first_heading: bool = False): """Content ctor.""" if title_aliases is None: title_aliases = [] self._orgids_linking = [] # Name of the original Org-roam file self.filename = filename # Node title self.title_org = '[no title]' self.title_runs = textruns.cut_into_runs(self.title_org) # Meta data about that Org-roam node self._meta = {} # Title aliases (from :ROAM_ALIASES) if title_aliases: self._meta['aliases_org'] = [] self._meta['aliases_runs'] = [] for one_alias in title_aliases: self._meta['aliases_org'].append(one_alias) self._meta['aliases_runs'].append( textruns.cut_into_runs(one_alias)) # list of content elements self._elements = [] # # the current active element # self._e = None self.parse_lines(body_lines) # When this Content instance represent a subheading with its own OrgID if anchor_first_heading: if not isinstance(self._elements[0], elements.Heading): raise TypeError('First element need to be a heading.') self._elements[0].generate_anchor() def _on_link_created(self, linkrun): if linkrun.link_type == textruns.Link.Type.ORGID: self._orgids_linking.append(linkrun.target) def __getitem__(self, idx: int): return self._elements[idx] def __len__(self): return len(self._elements) def add_subheading_content(self, content): """Add child node to this one. """ content.inherit_meta_fields_from(self) # Content element, subtree/heading with own OrgId # self._finish_current_element() # self._e = content self._elements.append(content) # self._finish_current_element() def inherit_meta_fields_from(self, content: Node): """Some meta fields from ``content`` are copied into the own. Which fields are copied is specified by `META_FIELDS_INHERIT'. Args: content: The content object to copy from. """ for field in Node.META_FIELDS_INHERIT: try: val = content._meta[field] # pylint: disable=protected-access except KeyError: pass else: own_val = self._meta.get(field, None) # just copy if own_val is None: self._meta[field] = val continue if not isinstance(own_val, list): own_val = [own_val] if not isinstance(val, list): val = [val] self._meta[field].extend(val) @classmethod def add_all_backlinks(cls, backlinks: Dict[Hashable, Dict[Hashable, Iterable]]): """Create backlinks elements for all nodes. The backlinks dict contains backlinks of all existing nodes like this example :: { 'node-A': { 'node-B': [], 'node-C': ['sub-C'], }, 'other-node': { 'node-X': [], } } "node-A" with two backlinks. One to "node-B" and one to the sub-heading "sub-C" of "node-C". "other-node" with one backlink to "node-X". """ for orgid, to_backlink_node in backlinks.items(): if orgid in cls.nodes: cls.nodes[orgid].add_backlinks(to_backlink_node) def as_link(self, own_orgid: str, subheading_idx: int = None ) -> textruns.Link: """Itself as a `Link`. Dev note (2025-03, buhtz): Nodes don't know their own OrgID. I am not sure anymore if this was such a good idea to keep the nodes that dumb. """ if subheading_idx is None: runs = self.title_runs else: runs = self[subheading_idx][0].runs return textruns.Link( target=own_orgid, link_type=textruns.Link.Type.ORGID, label_runs=runs ) def add_backlinks(self, backlinks: Dict[Hashable, Iterable]): """Create backlink element for a specific node. """ for orgid, to_backlink_node in backlinks.items(): # Create backlink element if there is not one if (not self._elements or not isinstance(self._elements[-1], elements.Backlinks)): self._elements.append( elements.Backlinks()) # create org-file line link = Node.nodes[orgid].as_link(orgid) # add the link self._elements[-1].add_link(link) # subheadings? for subheading_id in to_backlink_node: idx = Node.heading_ids[subheading_id][1] subheading_link = Node.nodes[orgid].as_link(subheading_id, idx) self._elements[-1].add_sub_link(subheading_link) def parse_lines(self, to_parse: list): """Create Content elements based on text lines. Classify a list of lines and create Content elements (e.g. Content.Paragraph) of it. To differentiate a list item from a head when an asterix is used as bullet it is important to check for heading first. """ with textruns.Link.event_link_created.register_temp( self._on_link_created): meta, elem = parser.OrgContentParser(to_parse).result() self._meta = {**self._meta, **meta} self._elements.extend(elem) try: # Title as runs is used in the

element. # But when handling (back)links it might be easier using the # original org-syntax not parsed into runs. self.title_org = self._meta.pop('title') self.title_runs = textruns.cut_into_runs(self.title_org) except KeyError: pass def get_orgid_links(self) -> list: """Give a list of OrgID's this content instance is linking to. Be aware that subheading content objects are ignored. Returns: List of OrgID's """ return self._orgids_linking hyperorg/src/hyperorg/elements.py000066400000000000000000000314641522421240700175400ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Collection of elements of on org file. In the concept of Hyperorg an element is a vertical run. See module `textruns` for further details. """ from __future__ import annotations import logging import uuid import re from typing import Optional, Union from dataclasses import dataclass from hyperorg import textruns _log = logging.getLogger(__name__) class Paragraph(textruns.Run): # pylint: disable=too-few-public-methods """A paragraph element.""" def __init__(self, lines: str | list[str] | None = None): # Workaround: See #191 if isinstance(lines, list): content = ' '.join(lines) else: content = lines runs = textruns.cut_into_runs(content) if content else [] super().__init__(runs=runs) # Dev note (2025-10, buhtz): Why do we use @dataclass? The reasons is # that the methods __repr__() and __eq__() are automatically generated. # Generation of __init__() is omitted because of "init=False" @dataclass(init=False) class Heading(textruns.Run): """A heading element.""" __slots__ = ('level', 'anchor', ) level: int anchor: str | None def __init__(self, label: str): stripped_label = label.lstrip() # lstrip from the second blank stripped_label = stripped_label[stripped_label.index(' '):].lstrip() runs = textruns.cut_into_runs(stripped_label) super().__init__(runs=runs) self.anchor = None """See `generate_anchor()` for details.""" # enum symbol idx_stop = len(label) - len(stripped_label) heading_symbol = label[:idx_stop].strip() # The title of a node is the first heading level. Because of # that the first content heading (one * bullet) is the second # level. """Heading level.""" self.level = len(heading_symbol) + 1 def generate_anchor(self): """Set a random and unique anchor string for that heading. That anchor is later use to link to that heading (e.g. ``Heading``. """ self.anchor = str(uuid.uuid4()) @dataclass(init=False) class ListElement(textruns.Run): """A list element including its entries. The list items are represented by `self.runs` (from `textruns.Run`). Each list item is of type `list()` even if it is only a `str`. One exception is a sub-list. This is an example of a nested list :: - one - two - three Representing with this class :: ListElement( runs=[ ['one'], ['two'], ListElement( runs=[ ['three'] ], ordered=False, _indention=2) ], ordered=False, _indention=0 ) """ __slots__ = ('ordered', '_indention', ) ordered: bool _indention: int | None def __init__( self, item: str, ordered: bool, indentation: int | None = None ): super().__init__(runs=[]) self.ordered = ordered """Switch for ordered list.""" self._indention = indentation """Indentation. From begin of a line until its bullet.""" self.add_item(item) def add_item(self, item: str): """Add list entry based on string in org syntax. Args: item: The complete line in org content incl. indentation whitespaces and item marker (e.g. bullet). """ if isinstance(item, list): self.add_item(item[0]) for item_part in item[1:]: self.append_to_last_item(item_part) return try: # split at whitespaces item_parts = item.split() except AttributeError: # Other than None is an error if item: raise # None is a valid value return # position of bullet indentation = item.index(item_parts[0]) # set indentation if not set yet if self._indention is None: # Indentation (to bullet). self._indention = indentation # higher indentation? if indentation > self._indention: # Active child? if isinstance(self[-1], ListElement): # add to existing child self[-1].add_item(item) else: # Check if item is (un)ordered and set this explicit child_ordered = item_parts[0][0].isdigit() # create new child list child = ListElement(item, child_ordered, indentation) self.runs.append(child) else: try: # lstrip from the second blank item_content = item[item.index(item_parts[1]):] except IndexError: # item without content e.g. "- " item_content = '' runs = textruns.cut_into_runs(item_content) self.runs.append(runs) def belongs_to_me(self, line: str) -> bool: """Checks if the indentation of 'line' belongs to this list element. """ try: return self.runs[-1].belongs_to_me(line) except AttributeError: pass # count whitespace blank_count = len(line) - len(line.lstrip()) # calculate content indentation if self.ordered: # e.g. '1. content' content_indention = 3 + self._indention else: # e.g. '- content' content_indention = 2 + self._indention if content_indention == blank_count: return True return False def append_to_last_item(self, line: str): """Append the 'line' to the last list item. """ if isinstance(self[-1], type(self)): # a child list self.runs[-1].append_to_last_item(line) elif isinstance(line, Block): # a block element self.runs[-1].append(line) else: blank = '' if isinstance(self.runs[-1][-1], Block) else ' ' new_runs = textruns.cut_into_runs(blank + line.lstrip()) self.runs[-1].extend(new_runs) @dataclass(init=False) class Block(textruns.Run): """Org syntax reference does name this a block. It does start with a line beginning with ``#+begin_NAME`` and end with a line ``#+end_NAME`` where ``NAME`` can be ``src``, ``example`` or something else. In org not all blocks are verbatim (e.g. ``begin_quote``). But this block here is meant to be verbatim. Open an issue please if you need support for other blocks. """ __slots__ = ('name', 'language') name: str language: str def __init__(self, name: str, content_lines: list[str], language: str = ''): super().__init__(runs=content_lines) self.language = language self.name = name @dataclass(init=False) class TableBlock(Block): """An Org table or table.el table displayed as a regular code block. It is a quick n dirty solution to handle tables. See Issue #143 about render tables as HTML. See https://orgmode.org/worg/org-syntax.html#Tables about syntax reference. """ def __init__(self, content_lines: list[str]): # Language "text" is used because the "org" lexer in pygments is known # not to support org- or tables.el style tables. # See: https://github.com/pygments/pygments/discussions/2688 super().__init__( name='src', content_lines=content_lines, language='text') @dataclass(init=False) class DisplayMath(textruns.Run): # pylint: disable=too-few-public-methods """Class for math (display) elements""" def __init__(self, content: str) -> None: super().__init__(runs=[content]) @dataclass(init=False) class Figure(textruns.Run): """A Figure contain an image and optionally a link.""" _REX_PROPERTY = re.compile(r'^#\+(.*?): *(.*)$') _REX_LABEL_TARGET = re.compile(r'^\[\[((?:(?!]\[).)*)(?:]\[(.*))?]]$') """Credits: https://stackoverflow.com/a/75996345/4865723""" __slots__ = ('attributes', 'unknown') attributes: dict[str, str] unknown: list[str] @staticmethod def to_properties_dict(lines: list) -> dict: """Extract information from lines beginning with `#+CAPTION`, `#+ATTR_HTML` or other org properties. Empty lines not allowed. Args: dash_plus: List of lines taken from an org file. Raises: ValueError: If lines are empty. Returns: A dictionary containing the extracted information. """ # pylint: disable=invalid-name result = {'attr': {}, 'caption': None, 'unknown': []} try: properties = [ Figure._REX_PROPERTY.match(line).groups() for line in lines] except AttributeError as exc: raise ValueError('Not all lines are org property lines!\n' '{}'.format('\n'.join(lines))) from exc for loop_prop, val in properties: prop = loop_prop.lower() # Caption if prop == 'caption': result['caption'] = val continue # Attributes if prop == 'attr_html': attr = ' ' + val attr = attr.split(' :')[1:] # first element is always empty for a in attr: k, v = a.split(' ', 1) result['attr'][k] = v continue # Something else result['unknown'].append(f'#+{prop.upper()}: {val}') return result def __init__( self, image_or_link: Union(textruns.Image, textruns.Link), properties: Optional[dict] = None ): """ The `line` is the full line (e.g. "[[image.png]]") from the org file without modification. The `properties` are all previous lines in the org file beginning (case insensitive) with `#+CAPTION` or `#+ATTR_HTML` and other org properties. """ if properties is None: properties = {} runs = [image_or_link] # Attributes self.attributes = properties.get('attr', {}) # Unknown org properties (e.g. '#+DOWNLOAD: xxx') self.unknown = properties.get('unknown', None) # Caption caption = properties.get('caption', None) if caption: caption = textruns.cut_into_runs(caption) runs.append(caption) super().__init__(runs=runs) self.attributes = self.image.set_attributes(self.attributes) @property def link(self) -> bool: """Return the link instance of that figure otherwise `None`.""" if isinstance(self[0], textruns.Link): return self[0] return None @property def image(self) -> textruns.Image: """Return the image instance of the figure.""" try: # first run of the Link if its label (the image) return self.link[0] except TypeError: # It is not a link so it must be an Image return self[0] @property def caption(self) -> list[textruns.RunsType]: """The figures caption as a list of runs. Returns: A list of runs. """ try: return self[1] except IndexError: return None class Backlinks(ListElement): """The backlinks of a node. Most of the time the backlinks are located in a section at the end of a node. """ def __init__(self): super().__init__(item=None, ordered=False, indentation=None) def add_link(self, link: textruns.Link) -> None: """Add a backlink. """ self.runs.append([link]) def add_sub_link(self, link: textruns.Link) -> None: """Add a backlink linking to a subheading instead of a node. That link is indented to the next level. """ # Add a sublist element if not present if not isinstance(self.runs[-1], ListElement): if not self._indention: indent = 0 else: indent = self._indention indent += 2 self.runs.append(ListElement( item=None, ordered=False, indentation=indent )) # Add link to the sublist self.runs[-1].runs.append([link]) hyperorg/src/hyperorg/event.py000066400000000000000000000035751522421240700170470ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Observer-Pattern like implementation. Regarding that pattern an object of type `Event` is the "Subject" and every object calling `Event.register()` is an "Observer". See https://stackoverflow.com/a/48339861/4865723 """ from contextlib import contextmanager class Event: """Instance of this event (aka subject) can notify its observers.""" def __init__(self): self._callbacks = [] def notify(self, *args, **kwargs): """Notify registered observers. The args, and kwargs are given to the observers. """ for callback in self._callbacks: callback(*args, **kwargs) def register(self, callback): """Register an observer (callback function). """ if callback not in self._callbacks: self._callbacks.append(callback) return callback def deregister(self, callback): """Deregister / remove an observer (callback function). """ self._callbacks.remove(callback) @contextmanager def register_temp(self, callback): """A context manager function to temporaryly register an observer. See `register()` for details. """ self.register(callback) yield self.deregister(callback) # @contextmanager # def keep_silent(self): # """A context manager function to suppress notification's of the # observers.""" # try: # self._silent_callbacks = self._callbacks # self._callbacks = [] # yield # finally: # self._callbacks = self._silent_callbacks hyperorg/src/hyperorg/exporter.py000066400000000000000000001334411522421240700175720ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under # GNU General Public License v3 (GPLv3). See LICENSES directory # or go to . # pylint: disable=too-many-lines """Exporting runs and elements to HTML""" import sys import logging import pathlib import datetime import locale import os import copy import secrets from collections import Counter from contextlib import contextmanager from typing import Union, Any, Dict, Iterable from html import escape as html_escape from packaging import version import hyperorg from hyperorg.content import Node from hyperorg import elements from hyperorg import textruns from hyperorg import stylecss from hyperorg import LoggingContext try: import pygments import pygments.lexers import pygments.formatters except ImportError: pygments = False _log = logging.getLogger(__name__) _TAB = ' ' * 4 _MSG_INSECURE_HTTP = 'For security reasons, HTTP URLs are not supported. ' \ 'Find an alternative HTTPS source.' @contextmanager def node_context(node_id: str): """Just a workaround. Will be replaced when there is a final decision about new design elements.""" Exporter.current_processing_node_id = node_id yield Exporter.current_processing_node_id = None class Exporter: # pylint: disable=too-few-public-methods """Class handling exporting runs and elements to HTML. Dev node: Consider to refactor and move most of it into module level. See Issue #111. """ _PLACEHOLDER_MATHJAX = '{{ mathjax }}' _PLACEHOLDER_TITLE = '{{ title }}' _PLACEHOLDER_BODY = '{{ body }}' _PLACEHOLDER_FOOTER = '{{ footer }}' _PLACEHOLDER_LANG = '{{ language }}' _PLACEHOLDER_INDEX_LETTER_NAV = '{{ index letter nav }}' _PLACEHOLDER_NODE_NAV = '{{ node nav }}' output_dir: pathlib.Path """Output directory where the HTML files are written into.""" output_dir = None use_hardlinks = False current_processing_node_id = None """Workaround""" def __init__(self, output_dir: pathlib.Path, use_hardlinks: bool = False): if use_hardlinks: _log.info('Using hardlinks instead of symlinks.') if not pygments: _log.info('Verbatim blocks without colors and syntax highlighting' ' because can not import package "pygments".') _log.info(f'Output to: {output_dir}') try: output_dir.mkdir(parents=True, exist_ok=True) except FileExistsError: _log.error(f'The output folder {output_dir} is an existing file. ' 'Use a folder instead.') sys.exit(1) Exporter.output_dir = output_dir Exporter.use_hardlinks = use_hardlinks self._footer_string = None self._hyperorg_css_content = None self._pygments_css_content = None @staticmethod def _as_html_filename(org_filename: str) -> str: """Translate an org-filename to its html pendant.""" return pathlib.Path(org_filename).with_suffix('.html') @staticmethod def _as_index_filename(base_name: str, mode: str) -> pathlib.Path: """Generate the name for an index HTML file. Args: base_name: Basis for the name (e.g. "MyTag") mode: Supplemental specifier (e.g. "filetag" or "year") Returns: The file path. """ return pathlib.Path(f'index__{mode}_{base_name}.html') def _clean_output_dir(self): """Remove all files from the output directory. """ logging.info('Delete all files and folders in the output directory.') out_dir = Exporter.output_dir # credits: https://stackoverflow.com/a/10886685/4865723 if list(out_dir.rglob('**/*.[oO][rR][gG]')): raise FileExistsError( f'There are ORG files in the output folder {out_dir}' '. Maybe you have confused this with the input folder?') # credits to myself # https://stackoverflow.com/a/72333396/4865723 file_objects = sorted(out_dir.rglob('**/*'), key=lambda v: (v.is_dir(), -len(v.parts))) logging.debug(f'{len(file_objects)} file objects') for fp in file_objects: if fp.is_file() or fp.is_symlink(): fp.unlink() elif fp.is_dir(): fp.rmdir() def _set_css_content(self): # noqa: PLR0915 """Create CSS content in the output directory. A ``pygments.css`` is created using `pygments`. A ``style.css`` is created if not present from the previous run of Hyperorg. If present but outdated it is replaced also by a new default ``style.css``. It is outdated if the version of current running Hyperorg is equal or higher than `stylecss.LAST_MODIFICATION`. Dev note: A refactor candidate! """ hyperorg_css_file_path = Exporter.output_dir / 'style.css' pygments_css_file_path = Exporter.output_dir / 'pygments.css' def _current_css_content(fp: pathlib.Path) -> str: """Read content of a CSS file if it exists. Otherwise return `None`. """ try: # use style from last hyperorg run with fp.open('r', encoding='utf-8') as handle: result = handle.read() except FileNotFoundError: result = None return result def _css_generated_by_previous_hyperorg_version(css_content: str ) -> str: """Extract from css file the version of Hyperorg used to generate the CSS content.""" if not css_content: return False try: # extract name and version of css file app, ver = stylecss.REX_GENERATOR.search(css_content).groups() except AttributeError: return False if app != hyperorg.__name__: _log.warning( 'Extracted application name from style.css ' f'was "{app}" but expected "{hyperorg.__name__}".') return None return ver self._hyperorg_css_content \ = _current_css_content(hyperorg_css_file_path) previous_version = _css_generated_by_previous_hyperorg_version( self._hyperorg_css_content) # WORKAROUND # Reason: The version check of style.css was not introduced in # version '0.1.0'. if not previous_version and hyperorg.__version__ == '0.1.1': previous_version = '0.1.0' try: previous_version = version.parse(previous_version) except TypeError: # No previous_version available. Assume the style.css is user # defined and don't touch it. if self._hyperorg_css_content: logging.info('It is assumed that the "style.css" was created ' 'by the user.') else: # no exception if previous_version < version.parse(stylecss.LAST_MODIFICATION): backup_fp = pathlib.Path(hyperorg_css_file_path) backup_fp = backup_fp.with_suffix( f'.{hyperorg.__name__}.{previous_version}' f'{backup_fp.suffix}') _log.warning( 'Found "style.css" generated by outdated version ' f'{previous_version} of {hyperorg.__name__}. It is renamed' f' to {backup_fp.name} and replaced by a fresh default ' f'"{hyperorg_css_file_path.name}".' ) hyperorg_css_file_path.rename(backup_fp) self._hyperorg_css_content = None if self._hyperorg_css_content: logging.info(f'Reuse "{hyperorg_css_file_path.name}".') else: logging.info( f'Generate fresh default "{hyperorg_css_file_path.name}".') self._hyperorg_css_content = stylecss.CSS_CONTENT # Pygments CSS if pygments: self._pygments_css_content \ = _current_css_content(pygments_css_file_path) if self._pygments_css_content: logging.info( f'Reuse "{pygments_css_file_path.name}".') else: logging.info('Generate Pygments CSS.') css = pygments.formatters.HtmlFormatter() \ .get_style_defs('pre') # remove
 tag style definitions
                css = css.split('\n')
                css = filter(lambda line: not line.startswith('pre { '), css)
                css = '\n'.join(css)

                # add comment to header
                css = stylecss.PYGMENTS_CSS_HEADER + css

                self._pygments_css_content = css

        else:
            self._pygments_css_content = None

    def _write_css_content(self):
        """
        # Create a CSS file in the empty output directory
        """
        def _write_css_helper(fp: pathlib.Path, content: str):
            with fp.open('w', encoding='utf-8') as handle:
                logging.debug(f'Write "{fp}".')
                handle.write(content)

        _write_css_helper(Exporter.output_dir / 'style.css',
                          self._hyperorg_css_content)

        if self._pygments_css_content:
            _write_css_helper(Exporter.output_dir / 'pygments.css',
                              self._pygments_css_content)

    def export_to_html(self):
        """Convert all content (nodes) to HTLM files.

        The whole content in 'Content.nodes' is converted to HTML and then
        stored as HTML files into the output directory ('self._output_dir').
        """

        # Eventually read the existing CSS file's content (into RAM)
        self._set_css_content()

        # Wipe the whole output directory from previous generated files
        self._clean_output_dir()

        # Create a CSS file in the empty output directory
        self._write_css_content()

        idx_items = Exporter._compute_indexes()

        logging.info(f'Start processing {len(Node.nodes)} nodes...')

        # each node
        for orgid, node in Node.nodes.items():
            with node_context(orgid):  # WORKAROUND
                with LoggingContext(f'"{node.title_org}"'):
                    # generate HTML content
                    html = self._as_html(node, idx_items)

                    # output filename
                    # html_filename = Exporter.generate_href_value(orgid)
                    # fp_out = Exporter.output_dir / html_filename
                    html_filename = Exporter._as_html_filename(node.filename)
                    fp_out = Exporter.output_dir / html_filename

                    fp_out.parent.mkdir(parents=True, exist_ok=True)
                    with fp_out.open('w', encoding='utf-8') as out_file:
                        out_file.write(html)

        def write_index_file(idx, fp):
            _log.debug(f'Index: {fp}')
            html_code = self._node_index_to_html(idx, idx_items)
            fp.write_text(html_code, encoding='utf-8')

        # generate the index files

        # main index
        write_index_file(
            idx=Node.index_data[Node.IDX_ALL],
            fp=Exporter.output_dir / 'index.html')

        # filetag based indexes
        for label, loop_fn, _ in idx_items[1:]:

            fn = Exporter._as_index_filename(loop_fn, Node.IDX_FILETAG)

            write_index_file(
                idx=Node.index_data[Node.IDX_FILETAG][label],
                fp=Exporter.output_dir / fn
            )

        logging.info(f'Wrote {len(Node.nodes)} nodes as HTML files.')

    def _node_index_to_html(self, index_dict: Dict, index_items: list):
        """Generates index files like ``index.html``.

        Args:
            index_dict: Filenames and titles of the index entries.
            index_items: Items used to create navigation bar.
        """

        letter_nav, body = self._node_index_to_html_body(index_dict)

        # full HTML structure
        html = Exporter._get_html_template_index()

        # Language
        lang = Exporter._get_html_lang_from_locale()
        html = html.replace(Exporter._PLACEHOLDER_LANG, lang)

        # Navigation bar
        node_nav = Exporter._index_items_as_html(index_items)
        html = html.replace(Exporter._PLACEHOLDER_NODE_NAV, node_nav)

        # Index letters
        html = html.replace(Exporter._PLACEHOLDER_INDEX_LETTER_NAV, letter_nav)

        # Body
        html = html.replace(Exporter._PLACEHOLDER_BODY, body)

        # Footer
        html = html.replace(Exporter._PLACEHOLDER_FOOTER,
                            self._get_footer())

        return html

    def _node_index_to_html_body(self, index_dict: Dict) -> str:
        """Create a list with titles and links to all nodes in HTML.

        Args:
            index_dict: A dictionary index by alphabet letters with lists of
                tuples each with title and filename.

        Return:
            The HTML content (body).
        """
        body = ''
        nav = ''

        # sort dict by key
        index_dict = {key: index_dict[key] for key in sorted(index_dict)}

        # each index letter
        for letter in index_dict:
            anchor = secrets.token_hex(8)
            # open letter-sec
            body += (f'{_TAB*3}
\n' f'{_TAB*3}
\n' # f'{_TAB*4}
' f'{_TAB*4}

{letter}

\n' f'{_TAB*4}
\n') # Back to Top - Link body += (f'{_TAB*5}Back to Top\n') # open list of titles body += f'{_TAB*5}
    \n' # node (title) reference for title, orgid, is_alias in sorted(index_dict[letter]): title_in_html = _html_from_runs(title) if is_alias: title_in_html = f'{title_in_html}' filename = Exporter.generate_href_value( orgid, start_node_id=None) body += (f'{_TAB*6}
  • ' f'{title_in_html}
  • \n') # close list and letter-sec body += (f'{_TAB*5}
\n' f'{_TAB*4}
\n' f'{_TAB*3}
\n') # add letter to index navigation nav += f'{_TAB*4}{letter}\n' return nav, body @staticmethod def _subtree_to_html(content: Node) -> str: """ """ return Exporter._body_and_meta(content, True) @staticmethod def _paragraph_to_html(paragraph: elements.Paragraph) -> str: """Generate HTML representation of a `Content.Paragraph` element. Args: p: The paragraph element. Returns: The paragraph as HTML using

tag. """ result = _html_from_runs(paragraph.runs) return f'

{result}

' @staticmethod def _figure_to_html(figure: elements.Figure) -> str: # linked image? link = figure.link if link: src = _html_from_link_run(link) # image else: src = _html_from_image_run(figure.image) src = f'{_TAB*3}{src}\n' # caption if figure.caption: cap = _html_from_runs(figure.caption) cap = f'{_TAB*3}
{cap}
\n' else: cap = '' # unknown properties ... if figure.unknown: tip = '\n'.join(figure.unknown) else: tip = '' # ... and unknown attributes (#+ATTR_HTML) ... if figure.attributes: if tip: tip = f'{tip}\n{figure.attributes}' else: tip = f'{figure.attributes}' # ... as tooltip if tip: tip = f' title="{tip}"' result = f'{_TAB*2}\n{src}{cap}{_TAB*2}' return result @staticmethod def _block_to_html(block: elements.Block) -> str: """Wrapper for `_html_from_block_run()`.""" return _html_from_block_run(block) @staticmethod def _displaymath_to_html(displaymath: elements.DisplayMath) -> str: content = html_escape(displaymath[0]) return f'
\\[{content}\\]
' @staticmethod def _heading_to_html(heading: elements.Heading) -> str: """ """ if heading.anchor: open_tag = f'' else: open_tag = f'' label = _html_from_runs(heading.runs) return f'{open_tag}{label}' @staticmethod def _list_to_html(alist: elements.ListElement) -> str: """Generate HTML representation of a `Content.List` element. The argument `replace_links` is used to deactivate links-to-html conversion in sub-lists in case of a nested list. That links are converted by the parent list object only. Args: alist: The `Content.List` element. replace_links: Only for internal use. Returns: The list as HTML source (``
    `` or ``
      ``). """ tag = 'ol' if alist.ordered else 'ul' items_html = '' for item in alist: # child list? if isinstance(item, elements.ListElement): # remove closing li-tag from item before items_html = items_html[:-5] # child list as html child_html = Exporter._list_to_html(item) # glue together items_html += f'\n{child_html}\n' else: item = _html_from_runs(item) items_html += f'\n
    • {item}
    • ' return f'<{tag}>{items_html}\n' @staticmethod def _backlinks_to_html(backlinks: elements.Backlinks) -> str: html = Exporter._list_to_html(backlinks) return '\n'.join([ f'{_TAB*2}', f'{_TAB*2}
      ', f'{_TAB*3}
      ', f'{_TAB*3}

      Backlinks

      ', f'{html}' ]) @staticmethod def _meta_other_fields_to_html(val, label): """See `Exporter._meta_to_html()`.""" if isinstance(val, list): val = [f'"{v}"' if ' ' in v else v for v in val] val = ' '.join(val) val = val.strip() return ('
      ' f'{label}: {val}
      ') @staticmethod def _as_locale_conform_date_string( val: Union[datetime.datetime, Any]) -> str: """Return `val` as a datetime string conform to 'locale'. Args: val: Can be a `datetime.datetime` instance or anything else. Returns: A string representation of `val`. """ # When Emacs org(mode) generates the date value via # %U they are enclosed by brackets try: if val.startswith('[') and val.endswith(']'): # remove brackets val = val[1:-1] except AttributeError: pass # locale time string if it is a datetime object try: val = val.strftime('%c') except AttributeError: pass return val @staticmethod def _mdate_cdate_html_string(val, date_key, date_label) -> str: return (f'
      \n' '\t' f'{date_label}: \n' '
      ') @staticmethod def _meta_to_html(content: Node, exclude_fields: list[str] = None) -> str: """Use the meta fields (`Content._meta`) and convert them to HTML. Args: content: A `Content` object. Returns: The HTML output. """ if exclude_fields is None: exclude_fields = [] # no meta data? if not content._meta: # pylint: disable=protected-access return '' result = [] meta = copy.deepcopy(content._meta) # pylint: disable=protected-access # exclude fields for exf in exclude_fields: meta.pop(exf, None) # Modification and creation date. mcdate_present = False for date_key, date_label in [('mtime', 'Modified'), ('ctime', 'Created')]: try: val = meta[date_key] mcdate_present = True val = Exporter._as_locale_conform_date_string(val) result.append( Exporter._mdate_cdate_html_string( val, date_key, date_label)) except KeyError: pass if result: result.append('
      ') # Title aliases for alias_runs in meta.get('aliases_runs', []): alias = _html_from_runs(alias_runs) result.append( f'{_TAB*3}
      \n' f'{_TAB*4}Alias: {alias}\n' f'{_TAB*3}
      ' ) # ignore modification, creation date and aliases exclude_names = ['mtime', 'ctime', 'aliases_runs', 'aliases_org'] # each meta entry for name in filter(lambda val: val not in exclude_names, meta): # current value val = meta[name] #
      ', '
      ', '
      ', '

      Backlinks

      ', '
        ', '
      • ', '', '⚠Over the rainbow', '', '
      • ', '
      ' ]) self.maxDiff = None self.assertEqual( unify_html(exporter.Exporter._backlinks_to_html(sut)), unify_html(expect) ) def test_one_valid(self): """One backlink with valid orgid""" target = 'rainbow' Node.nodes[target] = Node([], f'{target}.org') sut = elements.Backlinks() sut.add_link(textruns.Link( target=target, link_type=textruns.Link.Type.ORGID, label_runs=['Over the rainbow'] )) expect = ''.join([ '
      ', '
      ', '
      ', '

      Backlinks

      ', '' ]) self.maxDiff = None self.assertEqual( unify_html(exporter.Exporter._backlinks_to_html(sut)), unify_html(expect) ) hyperorg/tests/test_exporter_runs.py000066400000000000000000000312541522421240700204130ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Tests about generating HTML from Runs using `exporter._html_from_runs()`.""" # pylint: disable=protected-access,missing-function-docstring import unittest from hyperorg import exporter from hyperorg import textruns from hyperorg.content import Node def _reset_node_class(): Node.nodes = {} Node.heading_ids = {} Node.input_dir = 'input_dir' exporter.Exporter.output_dir = 'output_dir' class HtmlFromRuns_Markup(unittest.TestCase): # pylint: disable=C0103 """Test HTML out of markup runs""" def test_no_markup_text(self): runs = ['foobar'] self.assertEqual(exporter._html_from_runs(runs), 'foobar') def test_markup(self): runs = [ textruns.Markup('foobar', textruns.Markup.Type.IMPORTANT) ] self.assertEqual( exporter._html_from_runs(runs), 'foobar') class HtmlFromRuns_Links(unittest.TestCase): # pylint: disable=C0103 """Test HTML out of link runs""" # pylint: disable=protected-access # pylint: disable=missing-function-docstring # pylint: disable=too-many-public-methods @classmethod def setUpClass(cls): # pylint: disable=duplicate-code def _mock_me(*args, **kwargs): # pylint: disable=unused-argument pass cls._establish_file_link = exporter._establish_file_link exporter._establish_file_link = _mock_me @classmethod def tearDownClass(cls): # pylint: disable=duplicate-code exporter._establish_file_link = cls._establish_file_link def setUp(self): _reset_node_class() def test_orgid(self): Node.nodes['123'] = Node([], '123_foo.org') runs = [ textruns.Link.from_org_string('id:123][normal'), ' and ', textruns.Link.from_org_string('id:123][two words'), ' and ', textruns.Link.from_org_string('id:123][with\nlinebreak'), ] expect_html = 'normal and ' \ 'two words and ' \ 'with\nlinebreak' self.assertEqual(exporter._html_from_runs(runs), expect_html) def test_orgid_without_label(self): Node.nodes['123'] = Node([], '123_foo.org') runs = [textruns.Link.from_org_string('id:123')] expect_html = '123' self.assertEqual(exporter._html_from_runs(runs), expect_html) def test_orgid_target_unknown(self): """Org-roam-ID unknown with label""" runs = [textruns.Link.from_org_string('id:123][normal')] self.assertRegex( exporter._html_from_runs(runs), # title attribute, "Unknown", OrgID, label ... r'.*title=.*Unknown.*OrgID.*id\:123.*normal.*' # ... style class, label r'class=\"invalid-link\".*normal.*' ) def test_orgid_target_unknown_without_label(self): """Org-roam-ID unknown without a label""" runs = [textruns.Link.from_org_string('id:123')] self.assertRegex( exporter._html_from_runs(runs), # title attribute, "Unknown", OrgID, label ... r'.*title=.*Unknown*.OrgID.*id\:123.*' # ... style class, label r'class=\"invalid-link\".*123.*' ) def test_roam_unknown(self): """Roam link (is always unknown)""" # textruns.Link('roam:target'), runs = [textruns.Link.from_org_string('roam:word][label')] self.assertRegex( exporter._html_from_runs(runs), # title attribute, "Unknown", ROAM, label ... r'.*title=.*Unknown.*ROAM.*roam\:word.*' # ... style class, label r'class=\"invalid-link\".*label.*' ) def test_roam_unknown_without_label(self): """Roam link (is always unknown) without label""" # textruns.Link('roam:target'), runs = [textruns.Link.from_org_string('roam:word')] self.assertRegex( exporter._html_from_runs(runs), # title attribute, "Unknown", ROAM ... r'.*title=.*Unknown.*ROAM.*roam\:word.*' # ... style class, target r'class=\"invalid-link\".*word.*' ) def test_https(self): """Hyperlinks with and without labels to href.""" test_data = [ # Link with label ( [textruns.Link.from_org_string('https://foo.bar][foobar')], 'foobar' ), # Link without label ( [textruns.Link.from_org_string('https://foo.bar')], 'https://foo.bar' ), # two links with(out) label ( [ textruns.Link.from_org_string('https://foo.bar][foobar'), ' and ', textruns.Link.from_org_string('https://foo.bar') ], 'foobar and ' 'https://foo.bar' ), # two links other way around ( [ textruns.Link.from_org_string('https://foo.bar'), ' and ', textruns.Link.from_org_string('https://foo.bar][foobar') ], 'https://foo.bar and ' 'foobar' ), ] for runs, expect in test_data: self.assertEqual(exporter._html_from_runs(runs), expect) def test_https_linebreaks(self): """Hyperlinks with linebreaks.""" runs = [textruns.Link.from_org_string('https://foo.bar][foo\nbar')] expect = 'foo\nbar' self.assertEqual(exporter._html_from_runs(runs), expect) def test_doi(self): self.assertEqual( exporter._html_from_runs( [textruns.Link.from_org_string('doi:10.1055/a-1961-1145')] ), 'DOI: ' '10.1055/a-1961-1145' ) def test_doi_label(self): self.assertEqual( exporter._html_from_runs( [textruns.Link.from_org_string( 'doi:10.1055/a-1961-1145][Inanspruchnahme')] ), '' 'Inanspruchnahme' ) def test_mailto(self): self.assertEqual( exporter._html_from_runs( [textruns.Link.from_org_string('mailto:c.buhtz@posteo.jp')] ), 'c.buhtz@posteo.jp' ) def test_mailto_label(self): self.assertEqual( exporter._html_from_runs([ textruns.Link.from_org_string( 'mailto:c.buhtz@posteo.jp][Maintainer' ) ]), 'Maintainer' ) def test_file(self): self.assertEqual( exporter._html_from_runs( [textruns.Link.from_org_string('file:foobar.dat')] ), 'foobar.dat' ) def test_file_label(self): self.assertEqual( exporter._html_from_runs( [textruns.Link.from_org_string('file:foobar.dat][FOOBAR.DAT')] ), 'FOOBAR.DAT' ) def test_no_type(self): self.assertEqual( exporter._html_from_runs( [textruns.Link.from_org_string('foobar.dat')] ), 'foobar.dat' ) def test_no_type_label(self): self.assertEqual( exporter._html_from_runs( [textruns.Link.from_org_string('foobar.dat][LaBeL')] ), 'LaBeL' ) def test_unsecure_http(self): """HTTP links are not secure but will be parsed. See #29 about an improvement of that behavior unsafe (because of HTTP-only) hyperlinks not parsed """ runs = [textruns.Link.from_org_string('http://foo.bar][foobar')] self.assertRegex( exporter._html_from_runs(runs), # title attribute, "Unknown", ROAM, label ... r'.*title=.*security.*HTTP.*alternative.*http\://foo.bar.*' # ... style class, label r'class=\"insecure-link\".*foobar.*' ) def test_unsecure_http_without_label(self): """HTTP links are not secure but will be parsed. See #29 about an improvement of that behavior unsafe (because of HTTP-only) hyperlinks not parsed """ runs = [textruns.Link.from_org_string('http://foo.bar')] self.assertRegex( exporter._html_from_runs(runs), # title attribute, "Unknown", ROAM, label ... r'.*title=.*security.*HTTP.*alternative.*http\://foo.bar.*' # ... style class, label r'class=\"insecure-link\".*foo.bar.*' ) def test_unknown_type(self): """Link with unknown type (protocol).""" runs = [textruns.Link.from_org_string('fancytype:rainbow][label')] self.assertRegex( exporter._html_from_runs(runs), r'.*title=.*link type.*fancytype.*unknown.*rainbow.*label.*' r'class=\"invalid-link\".*fancytype.*label.*' ) def test_unknown_type_without_label(self): """Link with unknown type (protocol) without a label.""" runs = [textruns.Link.from_org_string('fancytype:rainbow')] self.assertRegex( exporter._html_from_runs(runs), r'.*title=.*link type.*fancytype.*unknown.*rainbow.*' r'class=\"invalid-link\".*fancytype.*rainbow.*' ) class HtmlFromRuns_InlineImages(unittest.TestCase): # pylint: disable=C0103 """Test HTML out of inline images""" # pylint: disable=protected-access @classmethod def setUpClass(cls): def _mock_me(*args, **kwargs): # pylint: disable=unused-argument pass cls._establish_file_link = exporter._establish_file_link exporter._establish_file_link = _mock_me @classmethod def tearDownClass(cls): exporter._establish_file_link = cls._establish_file_link def test_inline_image(self): """Inline image""" sut = [ 'foo', textruns.Image('target.png'), 'bar' ] self.assertEqual( exporter._html_from_runs(sut), 'footarget.pngbar' ) def test_inline_image_as_link(self): """Linked inline image""" sut = [ 'foo', textruns.Link.from_org_string('target.png][file:image.png'), 'bar' ] self.assertEqual( exporter._html_from_runs(sut), 'foo' '' 'image.png' 'bar' ) class HtmlFromRuns_Math(unittest.TestCase): # pylint: disable=C0103 """Test HTML out of Math runs""" def test_inline(self): sut = [textruns.InlineMath('e = mc^{2}')] expected_html = r'\(e = mc^{2}\)' self.assertEqual(exporter._html_from_runs(sut), expected_html) hyperorg/tests/test_hyperorg.py000066400000000000000000000025071522421240700173320ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Test about the hyperorg package (__main__.py)""" import unittest import subprocess import hyperorg import hyperorg.__main__ class TestHyperorg(unittest.TestCase): """Tests for __main__.py and __init__.py""" def _get_git_branch(self): return subprocess.check_output( ['git', 'symbolic-ref', '--short', 'HEAD']) \ .decode().replace('\n', '') def _get_git_commit_hash(self): return subprocess.check_output( ['git', 'log', '-n', '1', '--format=%H']) \ .decode().replace('\n', '') def test_git_repository_info(self): """Git version infos.""" self.assertEqual( { 'branch': self._get_git_branch(), 'hash': self._get_git_commit_hash()[:8], }, hyperorg.get_git_repository_info()) def test_simplify(self): """Remove unusual characters from a string""" self.assertEqual( hyperorg.simplify_string('<>()ads :-fäÜÖß'), '____ads__-fäÜÖß' ) hyperorg/tests/test_lint.py000066400000000000000000000202271522421240700164400ustar00rootroot00000000000000# Spdx-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Tests using several linters""" import unittest import os import logging import pathlib import subprocess from importlib import metadata from typing import Iterable # Not all linters follow strict PEP8 PEP8_MAX_LINE_LENGTH = 79 class MirrorMirrorOnTheWall(unittest.TestCase): """Check all py-files in the package incl. its test files if they are PEP8 compliant or don't smell and not having usual typos. """ @classmethod def setUpClass(cls): cls.PACKAGE_NAME = 'hyperorg' cls.TEST_FOLDER_NAME = pathlib.Path(__file__).parent.name cls.collected_py_files \ = cls._get_package_files() + cls._get_test_files() @classmethod def _get_test_files(cls) -> Iterable[pathlib.Path]: """Return list of Python files in the tests folder""" test_path = pathlib.Path.cwd() / cls.TEST_FOLDER_NAME return list(test_path.rglob('*.py')) @classmethod def _get_package_files(cls) -> Iterable[pathlib.Path]: """Return list of all py-files of the (editable) installed package.""" # get all files in this package files = metadata.files(cls.PACKAGE_NAME) files = [f.locate() for f in files] # py-files only files = filter( lambda fp: fp.suffix == '.py' # Python file and not fp.name.startswith('.#') # no editor temp files and fp.parent.name is not cls.TEST_FOLDER_NAME, # no test file files) return list(files) def test010_ruff(self): """Ruff in default mode.""" # ATTENTIION: Some settings are found in pyproject.toml cmd = [ 'ruff', 'check', '--line-length', str(PEP8_MAX_LINE_LENGTH), # Additionally activate subset of PyLint (PL) # and PyCodestyle (E, W) rules '--extend-select=PL,E,W', # But ignore... '--ignore=' 'PLR2004,' # magic value in comparison 'PLW2901', # `for` loop variable `e` overwritten by # assignment target # Ruff counting branches different from PyLint. # See: '--config', 'pylint.max-branches=13', '--config', 'flake8-quotes.inline-quotes = "single"', # one error per line (no context lines) '--output-format=concise', '--quiet', '.', ] proc = subprocess.run( cmd, check=False, universal_newlines=True, capture_output=True ) # No errors other then linter rules self.assertIn(proc.returncode, [0, 1], proc.stderr) error_n = len(proc.stdout.splitlines()) if error_n > 0: print('\n' + proc.stdout) # noqa: T201 self.assertEqual(0, error_n, f'Ruff found {error_n} problem(s).') # any other errors? self.assertEqual(proc.stderr, '') def test020_flake8(self): """Flake8 in default mode.""" cmd = [ 'flake8', f'--max-line-length={PEP8_MAX_LINE_LENGTH}', '--builtins=_,ngettext', # '--enable-extensions=' ] cmd.extend(str(v) for v in self.collected_py_files) proc = subprocess.run( cmd, check=False, universal_newlines=True, capture_output=True ) error_n = len(proc.stdout.splitlines()) if error_n > 0: print('') print(proc.stdout) self.assertEqual(0, error_n, f'Flake8 found {error_n} problem(s).') # any other errors? self.assertEqual(proc.stderr, '') def test030_pylint(self): """Use Pylint to check for specific error codes. Some facts about PyLint - It is one of the slowest available linters. - It is able to catch lints none of the other linters """ # Pylint base command cmd = [ 'pylint', # # Output format (default: text) # '--output-format=colorized', # Storing results in a pickle file is unnecessary '--persistent=n', # autodetec number of parallel jobs (pylint default) '--jobs=0', # Disable scoring ("Your code has been rated at xx/10") '--score=n', # PEP8 conform line length (see PyLint Issue #3078) f'--max-line-length={PEP8_MAX_LINE_LENGTH}', # # prevent false-positive no-module-member errors # '--extension-pkg-whitelist=' # 'pygments,pygments.formatters,pygments.formatters.html', # List of members which are set dynamically and missed by pylint # inference system, and so shouldn't trigger E1101 when accessed. '--generated-members=pygments.formatters.HtmlFormatter', # Allowlist variable names '--good-names=idx,fp,fn,maxDiff', # Allow fstrings when logging '--disable=logging-fstring-interpolation', # https://github.com/pylint-dev/pylint/issues/9547 '--ignored-argument-names=_', ] # Add py files cmd.extend(str(v) for v in self.collected_py_files) # subprocess.run(cmd, check=True) proc = subprocess.run( cmd, check=False, universal_newlines=True, capture_output=True) # Count lines except module headings error_n = len(list(filter( lambda line: line[:2] not in ('**', ' ', '==', ' (', ''), proc.stdout.splitlines()))) print(proc.stdout) # noqa: T201 if proc.returncode != 0: logging.error(proc.stdout) self.assertEqual(0, error_n, f'PyLint found {error_n} problem(s).') # any other errors? self.assertEqual(proc.stderr, '') def test040_codespell(self): """Codespell to check for common typos. Config can be found in pyproject.toml. """ # cmd = [ # 'codespell', # # Skip these files # '--skip', # '.git,.pytest_cache,.ruff_cache,build,*.egg-info,' # 'docs,#*.py#,.venv,venv,./example/html', # # Print N lines of surrounding context # '--context', '1', # # Check hidden files also # '--check-hidden', # # Dictionaries to use (default: "clear,rare"). Current: all. # '--builtin', # 'clear,rare,informal,usage,code,names,en-GB_to_en-US', # # Print number of errors as last line on stderr # '--count', # # Simulate "# noqa" and ignore all lines with # # "# codespell-ignore" at the end. # # Credits: https://github.com/codespell-project/codespell/ # # issues/1212#issuecomment-1721152455 # '--ignore-regex', '.*# codespell-ignore$', # # Allowed (ignored) words # '--ignore-words-list=assertIn', # # Allowed (ignored) words in URLs and URIs # '--uri-ignore-words-list=master', # '--enable-colors', # ] proc = subprocess.run( ['codespell'], check=False, universal_newlines=True, capture_output=True) error_n = int(proc.stderr) if error_n > 0: logging.error(proc.stdout) self.assertEqual(0, error_n, f'Codespell found {error_n} problem(s).') def test050_reuse_spdx(self): """Check REUSE Software and SPDX specifications""" env = os.environ env['LC_ALL'] = 'C' proc = subprocess.run( ['reuse', 'lint'], check=False, universal_newlines=True, env=env, capture_output=True) self.assertEqual(proc.returncode, 0, proc.stdout) hyperorg/tests/test_parser.py000066400000000000000000000555331522421240700167760ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Tests related to parser.py Org content parsed into runs and elements """ import unittest from hyperorg import textruns from hyperorg import elements from hyperorg import parser class Paragraphs(unittest.TestCase): """Parse paragraphs""" def test_single(self): """One paragraph with three lines.""" content = [ 'lorem ipsum dolor', 'sit amet,', 'consetetur sadipscing elitr' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.Paragraph) self.assertEqual( sut[0][:], ['lorem ipsum dolor sit amet, consetetur sadipscing elitr'] ) def test_three(self): """Three paragraphs.""" content = [ 'paragraph 1 lorem ipsum', 'lorem ipsum dolor', '', 'paragraph 2 dolor sit amet, consetetur', 'sadipscing elitr, sed', 'diam nonumy eirmod tempor', '', 'paragraph 3 invidunt', 'aliquyam erat,' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 3) for idx in range(2): self.assertIsInstance(sut[idx], elements.Paragraph) expected_paragraphs = [ # paragraph 1 'paragraph 1 lorem ipsum lorem ipsum dolor', # paragraph 2 'paragraph 2 dolor sit amet, consetetur sadipscing elitr, sed ' 'diam nonumy eirmod tempor', # paragraph 3 'paragraph 3 invidunt aliquyam erat,' ] for idx in range(0): self.assertEqual(sut[idx][:], expected_paragraphs[idx]) def test_mixed_with_blocks(self): """Blocks and paragraphs combined""" content = [ 'Foo', '#+begin_src python', 'import eins', 'print("eins")', '#+end_src', 'Bar', '#+begin_src Cpp', 'import zwei', 'print("zwei")', '#+end_src', 'Letzte Zeile' ] _, sut = parser.OrgContentParser(content).result() # five elements self.assertEqual(len(sut), 5) # the paragraphs self.assertEqual(sut[0], elements.Paragraph(['Foo'])) self.assertEqual(sut[2], elements.Paragraph(['Bar'])) self.assertEqual(sut[4], elements.Paragraph(['Letzte Zeile'])) self.assertEqual(sut[1], elements.Block( 'src', ['import eins', 'print("eins")'], 'python')) self.assertEqual(sut[3], elements.Block( 'src', ['import zwei', 'print("zwei")'], 'Cpp')) class Headings(unittest.TestCase): """Test parsing Headings""" def test_simple(self): """One heading""" content = ['* Heading'] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.Heading) self.assertEqual(sut[0][0], 'Heading') def test_multiple(self): """Three headings""" content = [ '* Heading 1', '* Heading 2', '* Heading 3' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 3) for idx in (1, 2, 3): self.assertEqual(sut[idx-1].runs, [f'Heading {idx}']) def test_heading_levels(self): """Headings on multiple levels.""" content = [ '* Heading 1', '** Heading 2', '*** Heading 3', '**** Heading 4', '***** Heading 5', '****** Heading 6', '******* Heading 7', '******** Heading 8', '********* Heading 9' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 9) for idx in range(9): self.assertTrue(isinstance(sut[idx], elements.Heading)) self.assertEqual(sut[idx].runs, [f'Heading {idx+1}']) def test_mix_with_paragraphs(self): """Mixing headings and paragraphs.""" content = [ '* Heading 1', 'lore ipsum', 'and more', '** Heading 2', 'one', '', 'two', '*Heading 3' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(sut[0], elements.Heading('* Heading 1')) self.assertEqual(sut[1][:], ['lore ipsum and more']) self.assertEqual(sut[2], elements.Heading('** Heading 2')) self.assertEqual(sut[3][:], ['one']) self.assertEqual(sut[4][:], ['two *Heading 3']) self.assertEqual(len(sut), 5) def test_paragraph_begin_with_markup(self): """The paragraph start with markup""" content = [ '* Heading', '*bold*: Foo.' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertEqual(sut[0], elements.Heading('* Heading')) self.assertEqual(len(sut[1]), 2) self.assertEqual( sut[1][0], textruns.Markup('bold', textruns.Markup.Type.IMPORTANT)) self.assertEqual(sut[1][1], ': Foo.') class Blocks(unittest.TestCase): """Parse (code) blocks""" def test_one_liner(self): """Codeblock with one line only""" content = [ '#+begin_src python', 'import hyperorg', '#+end_src' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) block = sut[0] self.assertIsInstance(block, elements.Block) # test block content self.assertEqual(block.name, 'src') self.assertEqual(block.language, 'python') self.assertEqual(block[0], 'import hyperorg') def test_multiple_lines(self): """Block contain multiple lines""" content = [ '#+begin_src python', 'import hyperorg', '', '', '', 'if __name__ == "__main__":', ' print("Hello World!")', '', ' sys.exit(1)', '#+end_src' ] _, sut = parser.OrgContentParser(content).result() # expected codeblock = elements.Block( name='src', content_lines=[ 'import hyperorg', '', '', '', 'if __name__ == "__main__":', ' print("Hello World!")', '', ' sys.exit(1)'], language='python') self.assertEqual(len(sut), 1) self.assertEqual(sut[0], codeblock) def test_two_blocks(self): """One codeblock follows the other""" content = [ '#+begin_src python', 'import eins', 'print("eins")', '#+end_src', '#+begin_src Cpp', 'import zwei', 'print("zwei")', 'foo bar', '#+end_src', ] pars = parser.OrgContentParser(content) _, sut = pars.result() self.assertEqual(len(sut), 2) python = sut[0] cpp = sut[1] # test block content self.assertEqual(python.language, 'python') self.assertEqual(len(python), 2) self.assertEqual(cpp.language, 'Cpp') self.assertEqual(len(cpp), 3) def test_case_insensitive(self): """The property code is case insensitive""" content = [ '#+bEgIn_Src python', 'import hyperorg', '#+enD_sRc' ] _, sut = parser.OrgContentParser(content).result() codeblock = elements.Block( name='Src', content_lines=['import hyperorg'], language='python') self.assertEqual(len(sut), 1) self.assertEqual(sut[0], codeblock) def test_no_language(self): """Missing language specifier""" content = [ '#+begin_foobar', 'import hyperorg', '#+end_foobar' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(sut[0].language, '') class TableBlocks(unittest.TestCase): """Test cases for quick n dirty solution to detect tables but render them as code blocks instead of HTML Syntax reference: https://orgmode.org/worg/org-syntax.html#Tables The pygments "text" lexer is used instead of "org" because the org lexer is known for not supporting tables. See: https://github.com/pygments/pygments/discussions/2688 """ def test_org_table(self): """Org table""" content = [ '| Name | Phone | Age |', '|-------+-------+-----|', '| Peter | 1234 | 24 |', '| Anna | 4321 | 25 |' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) block = sut[0] self.assertIsInstance(block, elements.TableBlock) # test block content self.assertEqual(block.name, 'src') self.assertEqual(block.language, 'text') for idx, content_line in enumerate(content): self.assertEqual(block[idx], content_line) def test_etable_table(self): """Org table""" content = [ '+-------+-------+-----+', '| Name | Phone | Age |', '+-------+-------+-----+', '| Peter | 1234 | 24 |', '+-------+-------+-----+', '| Anna | 4321 | 25 |', '+-------+-------+-----+' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) block = sut[0] self.assertIsInstance(block, elements.TableBlock) # test block content self.assertEqual(block.name, 'src') self.assertEqual(block.language, 'text') for idx, content_line in enumerate(content): self.assertEqual(block[idx], content_line) class Links(unittest.TestCase): """Parse links""" def test_paragraph(self): """Link contained in a paragraph""" content = ['ada [[https://foo.bar][mylink]] lovelance'] _, sut = parser.OrgContentParser(content).result() # one paragraph self.assertEqual(len(sut), 1) # with 3 runs self.assertEqual(len(sut[0]), 3) self.assertEqual(sut[0][0], 'ada ') self.assertEqual(sut[0][1].runs, ['mylink']) self.assertEqual(sut[0][1].target, 'foo.bar') self.assertEqual(sut[0][2], ' lovelance') def test_label_with_linebreak(self): """Link contained in a paragraph""" content = [ 'ada [[https://foo.bar][my', 'link]] lovelance' ] _, sut = parser.OrgContentParser(content).result() # one paragraph self.assertEqual(len(sut), 1) # with 3 runs self.assertEqual(len(sut[0]), 3) self.assertEqual(sut[0][0], 'ada ') self.assertEqual(sut[0][1].runs, ['my link']) self.assertEqual(sut[0][1].target, 'foo.bar') self.assertEqual(sut[0][2], ' lovelance') class Figures(unittest.TestCase): """Parse figures""" def test_simple(self): """Simple figure.""" content = [ '', '[[figure.bmp]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) figure = sut[0] self.assertEqual(figure.attributes, {}) self.assertEqual(figure.caption, None) self.assertEqual(figure.image.target, 'figure.bmp') self.assertEqual(figure.link, None) def test_linked_to_locale_file(self): """Figure with locale file linked image.""" content = ['[[link.bmp][file:figure.png]]'] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) figure = sut[0] self.assertEqual(figure.attributes, {}) self.assertEqual(figure.caption, None) self.assertEqual(figure.link.target, 'link.bmp') self.assertEqual(figure.image.target, 'figure.png') def test_figure_linked_to_https_file(self): """Figure with https file linked image.""" content = ['[[link.bmp][https://world.out/figure.png]]'] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) figure = sut[0] self.assertEqual(figure.attributes, {}) self.assertEqual(figure.caption, None) self.assertEqual(figure.link.target, 'link.bmp') self.assertEqual(figure.image.target, 'https://world.out/figure.png') def test_not_a_figure(self): """Missing "file:" prefix result in a paragraph with link""" content = ['[[link.bmp][figure.png]]'] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.Paragraph) self.assertEqual(sut[0][0].target, 'link.bmp') self.assertEqual(sut[0][0][0], 'figure.png') def test_two_figures(self): """Two figures""" content = [ 'foobar', '', '[[figure_one.png]]', '', '[[figure_two.png]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 3) self.assertEqual(sut[1].image.target, 'figure_one.png') self.assertEqual(sut[2].image.target, 'figure_two.png') def test_attr_width(self): """ATTR_HTML :width""" content = [ 'foobar', '#+ATTR_HTML: :width 123', '[[figure_one.png]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) sut = sut[1] self.assertEqual(sut.image.target, 'figure_one.png') self.assertEqual(sut.image.width, '123') def test_attr_alt(self): """ATTR_HTML :alt""" content = [ 'foobar', '#+ATTR_HTML: :alt foo bar', '[[figure_one.png]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) sut = sut[1] self.assertEqual(sut.attributes, {}) self.assertEqual(sut.image.target, 'figure_one.png') self.assertEqual(sut.image.alternative, 'foo bar') def test_caption(self): """CAPTION""" content = [ 'foobar', '#+CAPTION: ätsch', '[[figure.png]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) sut = sut[1] self.assertEqual(sut.attributes, {}) self.assertEqual(sut.caption, ['ätsch']) self.assertEqual(sut.image.target, 'figure.png') def test_attr_title(self): """ATTR_HTML :title""" content = [ 'foobar', '#+ATTR_HTML: :title This is mars', '[[figure_one.png]]' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) sut = sut[1] self.assertEqual(sut.attributes, {}) self.assertEqual(sut.image.title, 'This is mars') def test_unknown_property(self): """Unknown property""" content = [ 'foobar', '#+fooBar: pink elephant', '[[figure_one.png]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) sut = sut[1] self.assertEqual(sut.unknown, ['#+FOOBAR: pink elephant']) def test_multiple_properties(self): """Figure with multiple propierties""" content = [ 'foobar', '#+ATTR_HTML: :width 15%', '#+DOWNLOADED: https://foobar.world', '[[foobarfig.png]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) sut = sut[1] self.assertEqual(sut.image.target, 'foobarfig.png') self.assertEqual(sut.image.width, '15%') self.assertEqual(sut.unknown, ['#+DOWNLOADED: https://foobar.world']) def test_multiple_attr_html(self): """Figure with multiple ATTR_HTML lines""" content = [ 'foobar', '#+ATTR_HTML: :alt foo bar', '#+ATTR_HTML: :width 15%', '#+ATTR_HTML: :height 72%', '#+ATTR_HTML: :möp X', '#+ATTR_HTML: :title FOOBAR CITY', '[[foobarfig.png]]', ] _, sut = parser.OrgContentParser(content).result() # Figures Image sut = sut[1] self.assertEqual(sut.image.target, 'foobarfig.png') self.assertEqual(sut.image.alternative, 'foo bar') self.assertEqual(sut.image.width, '15%') self.assertEqual(sut.image.height, '72%') self.assertEqual(sut.image.title, 'FOOBAR CITY') # Figure self.assertEqual(sut.attributes, {'möp': 'X'}) def test_duplicate_attr_html(self): """Width overwrites the other.""" content = [ 'foobar', '#+ATTR_HTML: :width 15%', '#+ATTR_HTML: :width 39%', '[[foobarfig.png]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) sut = sut[1] self.assertEqual(sut.image.target, 'foobarfig.png') self.assertEqual(sut.image.width, '39%') class Math(unittest.TestCase): """Parse inline and display/block (latex) math""" def test_display(self): """Display (block) math marked by dollar sign or square brackets""" test_content = [ '$$e = mc^2$$', r'\[e = mc^2\]', ] for content in test_content: _, sut = parser.OrgContentParser([content]).result() self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.DisplayMath) self.assertEqual(sut[0][0], 'e = mc^2') def test_display_text_surrounded(self): """Display (block) math with text as pre- and suffix""" test_content = [ 'first paragraph $$e = mc^2$$ second one', r'first paragraph \[e = mc^2\] second one', ] for content in test_content: _, sut = parser.OrgContentParser([content, '']).result() self.assertEqual(len(sut), 3) self.assertIsInstance(sut[1], elements.DisplayMath) self.assertEqual(sut[1][0], 'e = mc^2') self.assertIsInstance(sut[0], elements.Paragraph) self.assertIsInstance(sut[2], elements.Paragraph) def test_inline(self): """Inline math marked marked by single dollar sign or round brackets """ test_content = [ '$e = mc^2$', r'\(e = mc^2\)', ] for content in test_content: _, sut = parser.OrgContentParser([content]).result() # one element only self.assertEqual(len(sut), 1) # a paragraph self.assertIsInstance(sut[0], elements.Paragraph) # contain one run only self.assertEqual(len(sut[0]), 1) # a inlinemath self.assertIsInstance(sut[0][0], textruns.InlineMath) self.assertEqual(sut[0][0][0], 'e = mc^2') def test_inline_text_surrounded(self): """Inline math marked with text as pre- and suffix""" test_content = [ 'one paragraph $e = mc^2$ with formula.', r'one paragraph \(e = mc^2\) with formula.', ] for content in test_content: _, sut = parser.OrgContentParser([content, '']).result() # one paragraph self.assertEqual(len(sut), 1) # with 3 runs self.assertEqual(len(sut[0]), 3) # second is inline math self.assertIsInstance(sut[0][1], textruns.InlineMath) self.assertEqual(sut[0][1][0], 'e = mc^2') # first and third are just strings self.assertEqual(sut[0][0], 'one paragraph ') self.assertEqual(sut[0][2], ' with formula.') def test_full(self): """All math variants as one""" content = [ 'first paragraph $$e = mc^2$$ second one', '', r'first paragraph \[e = mc^2\] second one', '', '$$e = mc^2$$', '', r'\[e = mc^2\]', '', 'one paragraph $e = mc^2$ with formula.', '', r'one paragraph \(e = mc^2\) with formula.', '', '$e = mc^2$', '', r'\(e = mc^2\)', ] _, sut = parser.OrgContentParser(content).result() # 12 elements self.assertEqual(len(sut), 12) # some Paragraphs for idx in [0, 2, 3, 5, 8, 9, 10, 11]: self.assertIsInstance(sut[idx], elements.Paragraph, f'{idx=}') # some DisplayMath for idx in [1, 4, 6, 7]: self.assertIsInstance(sut[idx], elements.DisplayMath, f'{idx=}') # 9th has inline math self.assertEqual(len(sut[8]), 3) self.assertIsInstance(sut[8][1], textruns.InlineMath) # 10th has inline math self.assertEqual(len(sut[9]), 3) self.assertIsInstance(sut[9][1], textruns.InlineMath) # 11th has inline math self.assertEqual(len(sut[10]), 1) self.assertIsInstance(sut[10][0], textruns.InlineMath) # 12th (last) has inline math self.assertEqual(len(sut[11]), 1) self.assertIsInstance(sut[11][0], textruns.InlineMath) def test_multiple_display_in_one_line(self): """Special case of multiple display math in one line""" # One line with 5 display math of dollar and bracket kind content = [ r'first para $$A = mc^2$$ second pAra \[B = mc^2\] third Para ' r'$$c = mc^2$$ forth \[D = mc^2\] five \[e = mc^2\]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 10) # some Paragraphs for idx in [0, 2, 4, 6, 8]: self.assertIsInstance(sut[idx], elements.Paragraph, f'{idx=}') # some DisplayMath for idx in [1, 3, 5, 7, 9]: self.assertIsInstance(sut[idx], elements.DisplayMath, f'{idx=}') def test_display_with_newline(self): """Display math with newline in it""" content = ['$$foo=\nbar$$'] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.DisplayMath) self.assertEqual(sut[0][0], 'foo=\nbar') hyperorg/tests/test_parser_lists.py000066400000000000000000000363661522421240700202170ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Tests about lists in org format converted into ListElements""" import unittest from hyperorg import textruns from hyperorg import elements from hyperorg import parser class Lists(unittest.TestCase): """Parse lists""" def test_ignore_single_empty_line(self): """Ignore one empty line between list items. A list ends with... - two blank lines - (bullet) less indented as line before """ # This is one list. The one empty line is ignored. content = [ '- one', '', '- two' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertEqual(sut[0][0], ['one']) self.assertEqual(sut[0][1], ['two']) def test_two_list(self): """Two lists separated by two empty lines""" # two lists content = [ '- one', '', '', '- two' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertEqual(sut[0][0], ['one']) self.assertEqual(sut[1][0], ['two']) def test_finish_with_paragraph(self): """List followed by a paragraph""" content = [ '- one', 'two' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertEqual(sut[0], elements.ListElement('- one', False)) self.assertEqual(sut[1], elements.Paragraph(['two'])) def test_finish_with_heading(self): """List followed by a heading""" content = [ '- one', '* two' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertEqual(sut[0], elements.ListElement('- one', False)) self.assertEqual(sut[1], elements.Heading('* two')) def test_one_item(self): """List with one item.""" content = [ '- one' ] _, sut = parser.OrgContentParser(content).result() # one element self.assertEqual(len(sut), 1) # a list element self.assertIsInstance(sut[0], elements.ListElement) # list content; first list item self.assertEqual(sut[0][0], ['one']) def test_multiple_items(self): """List with one item.""" content = [ '- one', '- two', '- three', ] _, sut = parser.OrgContentParser(content).result() # one element self.assertEqual(len(sut), 1) # a list element self.assertIsInstance(sut[0], elements.ListElement) # list content; first list item self.assertEqual(sut[0][0], ['one']) self.assertEqual(sut[0][1], ['two']) self.assertEqual(sut[0][2], ['three']) def test_unordered(self): """List with one item.""" content = [ '- one' ] _, sut = parser.OrgContentParser(content).result() self.assertFalse(sut[0].ordered) def test_ordered(self): """List with one item.""" content = [ '1. one' ] _, sut = parser.OrgContentParser(content).result() self.assertTrue(sut[0].ordered) def test_sub_list(self): """List with list""" content = [ '- one', ' - two' ] _, sut = parser.OrgContentParser(content).result() # one list self.assertEqual(len(sut), 1) # with two items self.assertEqual(len(sut[0]), 2) # first item self.assertEqual(sut[0][0], ['one']) # second item self.assertIsInstance(sut[0][1], elements.ListElement) self.assertEqual(sut[0][1][0], ['two']) def test_sub_list_in_the_middle(self): """List with list in the middle""" content = [ '- one', ' - two', '- three' ] _, sut = parser.OrgContentParser(content).result() # one list self.assertEqual(len(sut), 1) # with two items self.assertEqual(len(sut[0]), 3) # first item self.assertEqual(sut[0][0], ['one']) # third item self.assertEqual(sut[0][2], ['three']) # second item self.assertIsInstance(sut[0][1], elements.ListElement) self.assertEqual(sut[0][1][0], ['two']) def test_messup_whitespaces(self): """Unusual space between bullet and content""" content = [ ' - one', ' - two', ' - three' ] _, sut = parser.OrgContentParser(content).result() # one element self.assertEqual(len(sut), 1) # a list element self.assertIsInstance(sut[0], elements.ListElement) # list content; first list item self.assertEqual(sut[0][0], ['one']) self.assertEqual(sut[0][2], ['three']) # the sublist self.assertIsInstance(sut[0][1], elements.ListElement) self.assertEqual(sut[0][1][0], ['two']) def test_mixed_bullets(self): """The first bullet decides if the list is (un)ordered. They type of following bullets in the same list is ignored. """ # NOTE Schlägt fehl, weil hier die bullets gemixed sind. Der Parser # nutzt aber jeweils nur die Pattern für ORDERED lists und findet daher # in Zeile 2 und drei nichts. content = [ ' 12345. one', ' - two', ' - three' ] _, sut = parser.OrgContentParser(content).result() # one element self.assertEqual(len(sut), 1) # a list element self.assertIsInstance(sut[0], elements.ListElement) self.assertEqual(sut[0].ordered, True) self.assertEqual(sut[0][0], ['one']) self.assertEqual(sut[0][2], ['three']) # the sublist self.assertIsInstance(sut[0][1], elements.ListElement) self.assertFalse(sut[0][1].ordered) self.assertEqual(sut[0][1][0], ['two']) def test_list_or_heading_asterix_with_whitespace(self): """Special situation with asterix as list item bullet. An asterix as a list item bullet is allowed only if there is minimal one whitespace in front of it. If not it's not a list item but a heading. """ # list with two items content = [ ' * one', ' * two' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertEqual(len(sut[0]), 2) def test_list_or_heading_asterix_without_whitespace(self): """Special situation with asterix as list item bullet. An asterix as a list item bullet is allowed only if there is minimal one whitespace in front of it. If not it's not a list item but a heading. """ # it is a heading # no whitespace before the bullet content = [ '* one', '* two', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertEqual(sut[0], elements.Heading('* one')) self.assertEqual(sut[1], elements.Heading('* two')) def test_counters(self): """Multiple types of counters""" for bullet_a, bullet_b in [('1.', '2.'), ('1.', '1.'), ('1)', '2)')]: content = [ f'{bullet_a} one', f'{bullet_b} two' ] with self.subTest(f'{content=}'): _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.ListElement) def test_paragraph_before(self): """Paragraph before a list.""" content = [ 'paragraph', '- one' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertIsInstance(sut[0], elements.Paragraph) self.assertIsInstance(sut[1], elements.ListElement) class ListsWithLinebreaks(unittest.TestCase): """Parse lists with multiline items""" def test_one_item(self): """One item list with linebreak""" content = [ '- one', ' two' ] _, sut = parser.OrgContentParser(content).result() # one element self.assertEqual(len(sut), 1) # list content; first list item self.assertEqual(sut[0][0], ['one two']) def test_two_items(self): """Items with linebreaks. Items can have linebreaks if the indentation of the next line is correct. e.g. - correct item with line break. - this incorrect because of missing indentation. """ content = [ ' - one', ' two', ' - three' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertEqual(len(sut[0]), 2) self.assertEqual(sut[0][0], ['one two']) self.assertEqual(sut[0][1], ['three']) def test_sub_list_with_linebreak_items(self): """Sublist with multiline entries. Based on Issue #31""" content = [ '- eins', '- Lorem ipsum dolor sit amet, consectetur adipisici elit, ' 'sed eiusmod tempor', ' incidunt ut labore et dolore magna aliqua', ' - sub eins', ' - Xorem ipsum dolor sit amet, consectetur adipisici elit,', ' incidunt ut labore e dolore magna aliqua', ' - drei' ] _, sut = parser.OrgContentParser(content).result() # one main list element self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.ListElement) # three items self.assertEqual(len(sut[0]), 3) # item one self.assertEqual(sut[0][0], ['eins']) # item two self.assertEqual( sut[0][1], [ 'Lorem ipsum dolor sit amet, consectetur adipisici elit, ' 'sed eiusmod tempor' ' incidunt ut labore et dolore magna aliqua' ] ) # third item is a sub list sublist = sut[0][2] self.assertEqual(len(sublist), 3) # sub lists 1st item self.assertEqual(sublist[0], ['sub eins']) # sub lists 2nd item self.assertEqual( sublist[1], [ 'Xorem ipsum dolor sit amet, consectetur adipisici elit,' ' incidunt ut labore e dolore magna aliqua' ] ) # sub lists 3rd item (two runs) self.assertEqual(sublist[2], ['drei']) def test_sub_list_simple(self): """Sublist with simple multiline entries. Based on Issue #31""" content = [ '- one', '- two', ' two-next-line', ' - three', ' - four', ' four-next-line', ' - five' ] _, sut = parser.OrgContentParser(content).result() # one list self.assertEqual(len(sut), 1) # three items self.assertEqual(len(sut[0]), 3) # 1st item self.assertEqual(sut[0][0], ['one']) self.assertEqual(sut[0][1], ['two two-next-line']) # 3rd item is a sublist with 3 items sublist = sut[0][2] self.assertEqual(len(sublist), 3) # sublists 1st item self.assertEqual(sublist[0], ['three']) # sublists 2nd item (two runs) self.assertEqual(sublist[1], ['four four-next-line']) # sublists 3rd item self.assertEqual(sublist[2], ['five']) class ComplexLists(unittest.TestCase): """List items can be code blocks, images or something else that is itself and element and not just a run.""" def test_code_block(self): """List with one item.""" content = [ '- one', '- two', ' #+begin_src python', ' import hyperorg', ' #+end_src', '- three' ] _, sut = parser.OrgContentParser(content).result() # one list self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.ListElement) # with three items self.assertEqual(len(sut[0]), 3) self.assertEqual(sut[0][0], ['one']) self.assertEqual(sut[0][2], ['three']) item = sut[0][1] self.assertEqual(item[0], 'two') self.assertEqual(item[1].name, 'src') self.assertEqual(item[1].language, 'python') self.assertEqual(item[1][0], 'import hyperorg') def test_link(self): """Items with links""" content = [ ' - [[https://foo.bar][link]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertEqual(len(sut[0]), 1) expected_link = textruns.Link.from_org_string( 'https://foo.bar][link' ) self.assertEqual(sut[0][0], [expected_link]) def test_link_label_with_newline(self): """List item contain linebreak in linklabel""" content = [ ' - [[https://foo.bar][link', ' with newline]]', ] _, sut = parser.OrgContentParser(content).result() # one list self.assertEqual(len(sut), 1) # one item self.assertEqual(len(sut[0]), 1) # with one run self.assertEqual(len(sut[0][0]), 1) # items first run element (link) self.assertEqual(sut[0][0][0].runs, ['link with newline']) self.assertEqual(sut[0][0][0].target, 'foo.bar') def test_link_target_with_newline(self): """List item with link element having a newline in its URL. The result is an URL with a blank in it. Of course it breaks the URL but org html export does it the same way. """ content = [ ' - [[https://foo', ' .bar][link with newline]]', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertEqual(len(sut[0]), 1) self.assertEqual(len(sut[0][0]), 1) self.assertEqual(sut[0][0][0].runs, ['link with newline']) self.assertEqual(sut[0][0][0].target, 'foo .bar') def test_whitespace_lines(self): """Parse lines with spaces only""" content = [ '1. Gewünschtes Packet installieren:', ' #+begin_example', ' apt install', ' #+end_example', # this two "empty" but not zero-length lines are the "problem" ' ', 'Paragraph' ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertIsInstance(sut[0], elements.ListElement) self.assertIsInstance(sut[1], elements.Paragraph) hyperorg/tests/test_parser_props_meta.py000066400000000000000000000156501522421240700212230ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under GNU # General Public License v3 (GPLv3). # See folder LICENSES or go to . """Tests related to parser.py especially about properties, meta data and comments in org files.""" import unittest import datetime from hyperorg import elements from hyperorg import parser class Properties(unittest.TestCase): """Test org node with properties""" def test_simple(self): """Simple properties""" content = [ '#+foo', '#+bar and 42', ] sut = parser.OrgContentParser(content) self.assertEqual(len(sut.result()[1]), 0) # pylint: disable-next=protected-access self.assertEqual(sut._property_lines, content) class Comments(unittest.TestCase): """Ignore comments while parsing""" def test_inline(self): """Inline comment""" content = [ '# Inline comment', 'foo', '# Inline comment', '', 'bar', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertIsInstance(sut[0], elements.Paragraph) self.assertIsInstance(sut[1], elements.Paragraph) self.assertEqual(sut[0][0], 'foo') self.assertEqual(sut[1][0], 'bar') def test_block(self): """Simple figure.""" content = [ '#+BEGIN_COMMENT', 'block comment', '#+END_COMMENT', 'banana', '', '#+BEGIN_COMMENT', 'block comment', '#+END_COMMENT', 'strawberry', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertIsInstance(sut[0], elements.Paragraph) self.assertIsInstance(sut[1], elements.Paragraph) self.assertEqual(sut[0][0], 'banana') self.assertEqual(sut[1][0], 'strawberry') def test_block_not_case_sensitive(self): """Block comment mixed upper/lower case""" content = [ '#+BEGIN_comment', 'block comment', '#+end_COMMENT', 'banana', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 1) self.assertIsInstance(sut[0], elements.Paragraph) self.assertEqual(sut[0][0], 'banana') def test_invalid_inline(self): """This is not a comment""" content = [ '#invalid inline comment', '', 'foo', ] _, sut = parser.OrgContentParser(content).result() self.assertEqual(len(sut), 2) self.assertIsInstance(sut[0], elements.Paragraph) self.assertIsInstance(sut[1], elements.Paragraph) self.assertEqual(sut[0][0], '#invalid inline comment') self.assertEqual(sut[1][0], 'foo') class ParsingMeta(unittest.TestCase): """Parsing meta fields.""" # pylint: disable=protected-access def test_title(self): """Title set via parse lines.""" content = [ '#+title: Foobar', '#+foo: Bar', 'one' ] sut, elem = parser.OrgContentParser(content).result() # The title element (Heading) is not created by the parser but # by the Node calling the parser. self.assertEqual(sut, {'title': 'Foobar', 'foo': 'Bar'}) # Just the paragraph self.assertEqual(len(elem), 1) def test_meta_at_file_beginning(self): """Parse meta (property) lines at beginning of a file.""" content = [ '#+title: Foobar', '#+date: 3022-08-06 18:23', '#+foo: bar', 'one', 'two', 'three' ] meta, _ = parser.OrgContentParser(content).result() self.assertEqual( meta, { 'title': 'Foobar', 'date': datetime.datetime(3022, 8, 6, 18, 23), 'foo': 'bar' } ) def test_meta_in_file_body(self): """Possible meta (property) lines in the body of a file. The key point here is that "meta" lines in the file body are not parsed as meta data. """ content = [ '#+title: Foobar', '#+date: 3022-08-06 18:23', '#+foo: bar', 'one', 'two', '#+DOWNLOADED: url', 'three', '#+filetags: foo' ] meta, elem = parser.OrgContentParser(content).result() self.assertEqual( meta, { 'title': 'Foobar', 'date': datetime.datetime(3022, 8, 6, 18, 23), 'foo': 'bar', 'filetags': ['foo'], } ) self.assertEqual(len(elem[0]), 1) self.assertEqual(elem[0][0], 'one two three') def test_ignore_meta_missing_hash(self): """The '#' is missing.""" content = ['+foo: bar', 'three'] meta, elem = parser.OrgContentParser(content).result() self.assertEqual(meta, {}) self.assertEqual(len(elem), 1) def test_ignore_meta_missing_blank(self): """Missing blank after ':'.""" content = ['#+foo:bar', 'three'] meta, elem = parser.OrgContentParser(content).result() self.assertEqual(len(elem), 1) self.assertEqual(meta, {}) def test_filetags_complex(self): """Multiple filetags with multiple words""" content = ['#+filetags: One "Two zwei" Three'] sut, _ = parser.OrgContentParser(content).result() self.assertEqual(sut, {'filetags': ['One', 'Two zwei', 'Three']}) def test_filetags_simple(self): """One word filetag""" content = ['#+filetags: One'] sut, _ = parser.OrgContentParser(content).result() self.assertEqual(sut, {'filetags': ['One']}) class ParsingMetaDate(unittest.TestCase): """Parsing date fields in meta data.""" def test_date_without_brackets(self): """Date without brackets.""" content = ['#+date: 2021-11-28 16:20'] meta, _ = parser.OrgContentParser(content).result() self.assertEqual(meta, {'date': datetime.datetime(2021, 11, 28, 16, 20)}) def test_date_active(self): """Active dates with <>.""" content = ['#+date: <2021-11-28 16:20>'] meta, _ = parser.OrgContentParser(content).result() self.assertEqual(meta, {'date': datetime.datetime(2021, 11, 28, 16, 20)}) def test_date_inactive(self): """Inactive dates with [].""" content = ['#+date: [2021-11-28 16:20]'] meta, _ = parser.OrgContentParser(content).result() self.assertEqual(meta, {'date': datetime.datetime(2021, 11, 28, 16, 20)}) hyperorg/tests/test_reader.py000066400000000000000000000737201522421240700167420ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under # GNU General Public License v3 (GPLv3). See LICENSES directory # or go to . """Tests about reader.py. The Reader class using orgparser package to read multiple org files.""" # pylint: disable=too-many-lines import pathlib import inspect import logging from collections import defaultdict import unittest from unittest import mock from pyfakefs import fake_filesystem_unittest as pyfakefs_ut from hyperorg.reader import Reader from hyperorg.content import Node from hyperorg import elements from hyperorg import textruns def _reset_node_class(): Node.nodes = {} Node.heading_ids = {} Node.index_data = { 'all': defaultdict(list), 'filetag': defaultdict(lambda: defaultdict(list)) } org_raw_content = [ # node linking to another node ''':PROPERTIES: :ID: d197c651-f6c1-4e00-8d8e-ef13adac16e2 :END: #+title: hyperorg_unittest_1 #+date: [2022-04-21 Do 16:55] Test file for unittest [[id:b0fc62f1-2926-4eec-a654-eab8fcea46d9][hyperorg_unittest_2]]''', # the linked node ''':PROPERTIES: :ID: b0fc62f1-2926-4eec-a654-eab8fcea46d9 :END: #+title: hyperorg_unittest_2 #+date: [2022-04-21 Do 16:55] Second hyperorg test file''', # node with child node (heading with own Org-ID) ''':PROPERTIES: :ID: 4a2f8510-f57e-468e-9832-8ddecd3335ac :END: #+title: killme #+date: [2022-04-03 So 22:55] Governments of the Industrial World, * Heading you weary giants of flesh and steel, * Child :PROPERTIES: :ID: 95c99140-f044-450d-b631-6a5ff7608b2b :END: I come from Cyberspace, the new home of Mind, * One more On behalf of the future, I ask you of the past to leave us alone.''', # node with alias titles incl. subheadings ''':PROPERTIES: :ID: 5a2f8510-f57e-468e-9832-8ddecd3335ac :ROAM_ALIASES: aliasnode "node with alias" :END: #+title: killme #+date: [2022-04-03 So 22:55] * Foo bar * Bar :PROPERTIES: :ID: da6759cb-ba23-4042-9486-abc0c76895ad :ROAM_ALIASES: again "ein bar alias" :END: foo''' ] backlinks_raw_content = [ # node that is linked to ''':PROPERTIES: :ID: node-A :END: #+title: Node-A Governments of the Industrial World, * Heading Sub-A :PROPERTIES: :ID: sub-A :END: you weary giants of flesh and steel, I come from Cyberspace, the new home of Mind,''', # B ''':PROPERTIES: :ID: node-B :END: #+title: Node-B * Foo [[id:node-A][NodeA]]''', # C ''':PROPERTIES: :ID: node-C :END: #+title: Node-C * Heading Sub-C :PROPERTIES: :ID: sub-C :END: [[id:node-A][A]]''', # D ''':PROPERTIES: :ID: node-D :END: #+title: Node-D [[id:node-A][A]] * Heading Sub-D :PROPERTIES: :ID: sub-D :END: [[id:node-A][a]]''', # E ''':PROPERTIES: :ID: node-E :END: #+title: Node-E [[id:node-A][a]] [[id:node-A][a]] [[id:node-A][a]]''', # F ''':PROPERTIES: :ID: node-F :END: #+title: Node-F [[id:sub-A][sA]]''', # G ''':PROPERTIES: :ID: node-G :END: #+title: Node-G * Heading Sub-Ga :PROPERTIES: :ID: sub-Ga :END: [[id:node-A][a]] * Heading Sub-Gb :PROPERTIES: :ID: sub-Gb :END: [[id:node-A][a]]''', ] index_data_raw_content = [ # title and subheading title ''':PROPERTIES: :ID: node-A :END: #+title: Node-A Governments of the Industrial World, * Heading Sub-A :PROPERTIES: :ID: sub-A :END: you weary giants of flesh and steel, I come from Cyberspace, the new home of Mind,''', # title and alias ''':PROPERTIES: :ID: node-B :ROAM_ALIASES: Frühstücksflocken :END: #+title: Cerial Foo''', # subheading alias ''':PROPERTIES: :ID: node-C :END: #+title: Node-C * Heading Sub-C :PROPERTIES: :ID: sub-C :ROAM_ALIASES: "Dr. Doom" :END: Jimmy''', # Two Filetag ''':PROPERTIES: :ID: node-D :END: #+title: Node-D #+filetags: Wiki Person Foo''', # wiki ''':PROPERTIES: :ID: node-E :END: #+title: Node-E #+filetags: Wiki foobar''', # person ''':PROPERTIES: :ID: node-F :END: #+title: Node-F #+filetags: Person Ricky''', ] class General(unittest.TestCase): """General tests about the Reader""" def setUp(self): _reset_node_class() def test_input_dir_exists(self): """Input dir exists.""" with mock.patch('pathlib.Path') as mocked_path: mocked_path.exists.return_value = True with self.assertLogs() as cm_log: Reader(mocked_path) # one log message only self.assertEqual(len(cm_log.output), 1) # content of log message self.assertRegex( cm_log.output[0], r'^INFO.*reader:Input from.*' ) # exists called one only mocked_path.exists.assert_called_once() def test_input_dir_not_exists(self): """Input dir not exists.""" with mock.patch('pathlib.Path') as mocked_path: mocked_path.exists.return_value = False with self.assertLogs() as cm_log: # exit() with self.assertRaises(SystemExit): Reader(mocked_path) # exists() called mocked_path.exists.assert_called_once() # one log message only self.assertEqual(len(cm_log.output), 2) # content of log message self.assertRegex( cm_log.output[1], r'^ERROR.*reader:Input directory.*does not exists.$' ) def test_input_dir_store(self): """Storing input dir parameter.""" with mock.patch('pathlib.Path.exists', return_value=True): with mock.patch('pathlib.Path.is_dir', return_value=True): fn = 'foobar' Reader(pathlib.Path(fn)) self.assertEqual(str(Node.input_dir), fn) def test_title_index_letter(self): """Extract index letter from titles.""" # pylint: disable=protected-access test_data = [ ('foo', 'F'), ('Bar', 'B'), ('Ähm', 'A'), ('äm', 'A'), ('öhm', 'O'), ('Öhm', 'O'), ('ühm', 'U'), ('Ühm', 'U'), ('=jup', '='), ('ßjup', 'S'), ('foo =bar=', 'F'), # This is not implemented yet. # ('=foo= bar', 'F'), ] for title, letter in test_data: self.assertEqual( Reader._title_to_index_letter(title), letter, (title, letter)) def test_add_to_index_data(self): """Correct index data based on a new node""" # pylint: disable=protected-access self.maxDiff = None # empty index data self.assertEqual( list(Node.index_data.keys()), ['all', 'filetag'] ) self.assertEqual(Node.index_data['all'], {}) self.assertEqual(Node.index_data['filetag'], {}) # create content node = Node( body_lines=[ '#+title: Titel', '#+filetags: My Tag', 'lore ipsum'], filename='', title_aliases=['Peter', 'Lustig'] ) # add it to index data Reader._add_to_index_data(node, '1234') expect = { 'all': { 'L': [(['Lustig'], '1234', True)], 'P': [(['Peter'], '1234', True)], 'T': [(['Titel'], '1234', False)] }, 'filetag': { 'My': { 'L': [(['Lustig'], '1234', True)], 'P': [(['Peter'], '1234', True)], 'T': [(['Titel'], '1234', False)] }, 'Tag': { 'L': [(['Lustig'], '1234', True)], 'P': [(['Peter'], '1234', True)], 'T': [(['Titel'], '1234', False)] } } } self.assertEqual(Node.index_data, expect) class RealFiles(pyfakefs_ut.TestCase): """Test with real files on a fake filesystem""" # pylint: disable=protected-access def setUp(self): """Create a folder with three org files.""" Node.nodes = {} Node.heading_ids = {} Node.backlinks = {} self.setUpPyfakefs(allow_root_user=False) self.input_dir = pathlib.Path('/test_reader_RealFiles-input_dir') self.input_dir.mkdir() def _create_files(self, fn_idx=None): """ """ if not fn_idx: self._create_files([ ('test1.org', 0), ('test2.org', 1), ('killme.org', 2), ('alias.org', 3)]) else: for fn, idx in fn_idx: with (self.input_dir / fn).open('w', 'utf-8') as handle: handle.write(inspect.cleandoc(org_raw_content[idx])) # create additional file that should be ignored with (self.input_dir / 'ignore.me').open('wb') as handle: handle.write(b'foobar') def test_dotfiles(self): """Ignore dotfiles. Such files can appear as hidden files from text editors. """ self._create_files() # make on into a hidden dotfile path = self.input_dir / 'test1.org' path.rename(path.parent / f'.{path.name}') file_count = len(list(self.input_dir.glob('*.org'))) # Five files do exist. One is the dotfile. self.assertEqual(file_count, 4) reader = Reader(self.input_dir) reader.read_org_files() # But only four should have been read. self.assertEqual(len(Node.nodes), 3) def test_alias_titles(self): """Alias titles for node and subheadings. """ self._create_files([('alias.org', 3)]) # read the files reader = Reader(self.input_dir) reader.read_org_files() nodeid = '5a2f8510-f57e-468e-9832-8ddecd3335ac' # subid = 'da6759cb-ba23-4042-9486-abc0c76895ad' node = Node.nodes[nodeid] self.assertEqual( node._meta['aliases_org'], ['aliasnode', 'node with alias'] ) self.assertEqual( node._elements[2]._meta['aliases_org'], ['again', 'ein bar alias'] ) def test_read_org(self): """Read org-files via orgparse.""" # Prepare the filesystem (only two files) self._create_files([('test1.org', 0), ('test2.org', 1)]) self.assertEqual( [p.name for p in self.input_dir.glob('*')], ['test1.org', 'test2.org', 'ignore.me'] ) # read the files reader = Reader(self.input_dir) reader.read_org_files() # two nodes self.assertEqual(len(Node.nodes), 2) # check first node's content key = 'd197c651-f6c1-4e00-8d8e-ef13adac16e2' self.assertEqual(len(Node.nodes[key]._elements), 2) self.assertEqual(list(Node.nodes[key]._meta.keys()), ['date']) self.assertEqual(Node.nodes[key].title_org, 'hyperorg_unittest_1') self.assertEqual(Node.nodes[key][0][0], 'Test file for unittest') link_run = Node.nodes[key][1][0] self.assertEqual( link_run.target, 'b0fc62f1-2926-4eec-a654-eab8fcea46d9') self.assertEqual(link_run[0], 'hyperorg_unittest_2') # check second node's content key = 'b0fc62f1-2926-4eec-a654-eab8fcea46d9' node = Node.nodes[key] self.assertEqual(len(node), 2) # incl. backlinks self.assertEqual(list(node._meta.keys()), ['date']) self.assertEqual(node.title_org, 'hyperorg_unittest_2') self.assertEqual(node[0][0], 'Second hyperorg test file') # 2nd nodes backlink self.assertIsInstance(node._elements[1], elements.Backlinks) link_run = node[1][0][0] self.assertEqual( link_run.target, 'd197c651-f6c1-4e00-8d8e-ef13adac16e2') self.assertEqual(link_run[0], 'hyperorg_unittest_1') @mock.patch('uuid.uuid4') def test_read_org_with_child_node(self, mock_uid): """Org-file with own ID for a heading/subtree.""" mock_uid.return_value = '987654' self._create_files([('killme.org', 2)]) self.assertEqual( [p.name for p in self.input_dir.glob('*.org')], ['killme.org'] ) # read the files reader = Reader(self.input_dir) reader.read_org_files() node_ids = [ # OrgID of the node '4a2f8510-f57e-468e-9832-8ddecd3335ac', # OrgID of the subheading '95c99140-f044-450d-b631-6a5ff7608b2b'] # The headings with their own OrgId (also known as subtree) # are created as an element of type hyperorg.Content. # That instances are not listed in Content.nodes but in # Content.heading_ids # only one subheading orgid self.assertEqual(len(Node.heading_ids), 1) self.assertEqual(list(Node.heading_ids)[0], node_ids[1]) # its value is a tuple of the parents OrgId and its own element # index in that parents element list self.assertEqual(Node.heading_ids[node_ids[1]], (node_ids[0], 3)) # the parent node self.assertEqual(len(Node.nodes), 1) self.assertEqual(list(Node.nodes)[0], node_ids[0]) self.assertIsInstance(Node.nodes[node_ids[0]], Node) # one content/node self.assertEqual(len(Node.nodes), 1) # ...and its OrgID node = Node.nodes[node_ids[0]] # one heading/subtree with OrgID self.assertEqual(len(Node.heading_ids), 1) # ...and its OrgID heading_orgid, heading_idx = Node.heading_ids[node_ids[1]] # heading points to the real node self.assertEqual(heading_orgid, node_ids[0]) # ...and to the corresponding content element self.assertEqual(heading_idx, 3) # 4nd element self.assertIsInstance(node[heading_idx], Node) # check each element self.assertEqual(len(node), 6) self.assertIsInstance(node[0], elements.Paragraph) self.assertIsInstance(node[1], elements.Heading) self.assertIsInstance(node[2], elements.Paragraph) self.assertIsInstance(node[3], Node) self.assertIsInstance(node[4], elements.Heading) self.assertIsInstance(node[5], elements.Paragraph) # check the sub-Content element subtree = node[heading_idx] self.assertEqual(len(subtree), 2) self.assertIsInstance(subtree[0], elements.Heading) self.assertIsInstance(subtree[1], elements.Paragraph) # anchor for first heading generated? self.assertEqual(subtree[0][0], 'Child') self.assertEqual(subtree[0].anchor, '987654') def test_read_org_sub_heading(self): """Org-file with own ID for a heading/subtree.""" self._create_files([('killme.org', 2)]) self.assertEqual( [p.name for p in self.input_dir.glob('*.org')], ['killme.org'] ) # read the files reader = Reader(self.input_dir) reader.read_org_files() self.assertEqual(len(Node.nodes), 1) self.assertEqual(len(Node.heading_ids), 1) node = list(Node.nodes.values())[0] self.assertEqual(node.title_org, 'killme') self.assertEqual(node[3].title_org, 'Child') def test_node_with_subheading(self): """Node with a subheading (result in a child node)""" # create file fn = 'foo.org' with (self.input_dir / fn).open('w', 'utf-8') as handle: body_lines = [ ':PROPERTIES:', ':ID: f7c0d0d1-3d4c-4bcc-96c0-6fa19036f21e', ':END:', '#+title: Issue93sub', '* Heading', 'Foobar', '* Next Heading', ':PROPERTIES:', ':ID: c07df761-b328-4cde-8433-9343cee055c6', ':END:', '- Barfoo' ] handle.write('\n'.join(body_lines)) # read the files reader = Reader(self.input_dir) reader.read_org_files() self.assertEqual(len(Node.nodes), 1) self.assertEqual(len(Node.heading_ids), 1) node = list(Node.nodes.values())[0] self.assertEqual(node.title_org, 'Issue93sub') self.assertEqual(node[2].title_org, 'Next Heading') def test_invalid_unicode_char(self): """Handle orgparse exception about invalid unicode characters""" # create file fn = 'invalid_char.org' with (self.input_dir / fn).open('wb') as handle: handle.write(b':PROPERTIES:\n') handle.write(b':ID: f7c0d0d1\n') handle.write(b':END:\n') handle.write(b'nicht-UTF-8-kodierten Zeichen: \xfc') handle.write(b' ende') # read the files with self.assertLogs(level=logging.ERROR) as cm_log: reader = Reader(self.input_dir) reader.read_org_files() # one error self.assertEqual(len(cm_log.output), 1) self.assertRegex( cm_log.output[0], r'.*invalid start byte.*UnicodeDecodeError.*$' ) class Globbing(pyfakefs_ut.TestCase): """Test globbing of files""" @classmethod def setUpClass(cls): cls.setUpClassPyfakefs() # pylint: disable=no-member cls.input_dir = pathlib.Path('/') / 'alpha' / 'quadrant' folders = [ 'bajor', '.gamma' ] files = [ 'picard.org', '.quark.org', 'grund.img', 'bajor/kira.oRg', 'bajor/.opaka.ORG', 'bajor/kaivin.png', '.gamma/wayon.orG', '.gamma/far.org' ] # create folders for fn in folders: fp = cls.input_dir / fn fp.mkdir(parents=True) # create files for fn in files: fp = cls.input_dir / fn fp.touch() def test_globbing(self): """Test finding files""" # pylint: disable=protected-access reader = Reader(self.input_dir) expected = [ self.input_dir / 'picard.org', self.input_dir / 'bajor' / 'kira.oRg' ] result = reader._glob_from_input_dir() self.assertEqual(sorted(expected), sorted(result)) class Backlinks(pyfakefs_ut.TestCase): """Test handling of backlinks""" def setUp(self): """Create a folder with three org files.""" # reset Content Node.nodes = {} Node.heading_ids = {} # fake filesystem self.setUpPyfakefs(allow_root_user=False) self.input_dir = pathlib.Path('/test_reader_Backlinks-input_dir') self.input_dir.mkdir() # create org files for idx, base in enumerate('ABCDEFG'): fp = self.input_dir / f'node_{base}.org' with fp.open('w', 'utf-8') as handle: handle.write(inspect.cleandoc(backlinks_raw_content[idx])) def _delete_nodes(self, node_orgids): """ """ for orgid in node_orgids: fp = self.input_dir / f'node_{orgid}.org' fp.unlink() def test_simple(self): """Simple node link from A to B.""" # pylint: disable=protected-access # keep A and B self._delete_nodes(list('CDEFG')) # A and B are read now. reader = Reader(self.input_dir) reader.read_org_files() self.assertEqual(Node.nodes['node-A']._orgids_linking, []) self.assertEqual(Node.nodes['node-B']._orgids_linking, ['node-A']) # B does link to A. # Because of that A need to have a backlink to B. backlink_element = Node.nodes['node-A'][2] self.assertIsInstance(backlink_element, elements.Backlinks) link_run = backlink_element[0][0] self.assertEqual(link_run.target, 'node-B') self.assertEqual(link_run[0], 'Node-B') self.assertEqual(link_run.link_type, textruns.Link.Type.ORGID) def test_link_from_subheading(self): """Sub-heading links to a node. The subheading "sub-C" does link to "A" which is just one link but results in two backlinks including the parent of the subheading: - Node-C - Headinb Sub-C """ # keep A and C self._delete_nodes(list('BDEFG')) reader = Reader(self.input_dir) reader.read_org_files() ble = Node.nodes['node-A'][2] # the parent link self.assertEqual(ble[0][0].target, 'node-C') # the subheading link in a sub-list self.assertEqual(ble[1][0][0].target, 'sub-C') def test_link_from_node_and_subheading(self): """Node and its Sub-heading linking a node.""" # keep A and D self._delete_nodes(list('BCEFG')) reader = Reader(self.input_dir) reader.read_org_files() # Backlinks of Node-A ble = Node.nodes['node-A'][-1] # Count of 1st level entries: parent + subheading self.assertEqual(len(ble), 2) # Count of 2nd level entries: the subheading only self.assertEqual(len(ble[1]), 1) self.assertEqual(ble[0][0].target, 'node-D') self.assertEqual(ble[1][0][0].target, 'sub-D') def test_three_duplicated_links(self): """Three links from one node.""" # keep A and E self._delete_nodes(list('BCDFG')) reader = Reader(self.input_dir) reader.read_org_files() ble = Node.nodes['node-A'][2] # "node-E" liks three times to "A", but duplicates not allowed self.assertEqual(len(ble), 1) self.assertEqual(ble[0][0].target, 'node-E') def test_two_backlinks(self): """Node A with two backlinks. ListElement( ordered=False, _indention=0, runs=[ [ Link(runs=['Enno Swart'], kind=, target='8988edc3-725c-4037-91d6-0b693a94c374') ], [ Link(runs=['Zettelkasten'], kind=, target='c107fe6b-cd42-4b52-b764-5dec62809cc8') ] ] ) """ # keep A, B, C self._delete_nodes(list('DEFG')) reader = Reader(self.input_dir) reader.read_org_files() self.assertEqual(len(Node.nodes), 3) node = Node.nodes['node-A'] back = node[2] self.assertIsInstance(back, elements.Backlinks) self.assertIsInstance(back[0][0], textruns.Link) self.assertIsInstance(back[1][0], textruns.Link) self.assertIsInstance(back[2], elements.ListElement) self.assertIsInstance(back[2][0][0], textruns.Link) # 1st link self.assertEqual(back[0][0].target, 'node-B') self.assertEqual(back[0][0].runs, ['Node-B']) # 2nd link self.assertEqual(back[1][0].target, 'node-C') self.assertEqual(back[1][0].runs, ['Node-C']) # 3rd link (to a subheading of node-c) self.assertEqual(back[2][0][0].target, 'sub-C') self.assertEqual(back[2][0][0].runs, ['Heading Sub-C']) def test_link_to_subheading(self): """Node links to a subheading.""" # keep A and F self._delete_nodes(list('BCDEG')) reader = Reader(self.input_dir) reader.read_org_files() ble = Node.nodes['node-A'][2] # "node-F" links to A's subheading "sub-A" self.assertEqual(ble[0][0].target, 'node-F') def test_link_from_two_subheadings(self): """Node links from two subheadings.""" # keep A and G self._delete_nodes(list('BCDEF')) reader = Reader(self.input_dir) reader.read_org_files() ble = Node.nodes['node-A'][2] # "sub-Ga" and "sub-Gb" linking to "node-A" self.assertEqual(ble[0][0].target, 'node-G') self.assertEqual(ble[1][0][0].target, 'sub-Ga') self.assertEqual(ble[1][1][0].target, 'sub-Gb') def test_dead_link(self): """Link to unexisting node. When a node links to a node that doesn't exists a backlink can not be created at the backlinked node because it doesn't exist. E.g. A links to X but X was deleted before. """ class IndexData(pyfakefs_ut.TestCase): """Extracting index data from the nodes. Titles, alias titles, filetags, index letters, etc TODO - test_add_to_index_data() (also for sub-heaings!) """ def setUp(self): """Create a folder with three org files.""" # reset Content Node.nodes = {} Node.heading_ids = {} Node.index_data = { Node.IDX_ALL: defaultdict(list), Node.IDX_FILETAG: defaultdict(lambda: defaultdict(list)) } # fake filesystem self.setUpPyfakefs(allow_root_user=False) self.input_dir = pathlib.Path('/test_reader_IndexData-input_dir') self.input_dir.mkdir() # create org files for idx, base in enumerate('ABCDEF'): fp = self.input_dir / f'node_{base}.org' with fp.open('w', 'utf-8') as handle: handle.write(inspect.cleandoc(index_data_raw_content[idx])) def _delete_nodes(self, node_orgids): """ """ for orgid in node_orgids: fp = self.input_dir / f'node_{orgid}.org' fp.unlink() def test_simple(self): """One node with sub-heading""" self.maxDiff = None # keep A self._delete_nodes(list('BCDEF')) reader = Reader(self.input_dir) reader.read_org_files() expected = { Node.IDX_ALL: { 'H': [ (['Heading Sub-A'], 'sub-A', False) ], 'N': [ (['Node-A'], 'node-A', False) ] }, Node.IDX_FILETAG: { Node.IDX_NO_TAG: { 'H': [ (['Heading Sub-A'], 'sub-A', False) ], 'N': [ (['Node-A'], 'node-A', False) ] } } } result = Node.index_data self.assertDictEqual(result, expected) def test_aliases(self): """One node with alias title""" self.maxDiff = None # keep A self._delete_nodes(list('ACDEF')) reader = Reader(self.input_dir) reader.read_org_files() expected = { Node.IDX_ALL: { 'C': [ (['Cerial'], 'node-B', False) ], 'F': [ (['Frühstücksflocken'], 'node-B', True) ] }, Node.IDX_FILETAG: { Node.IDX_NO_TAG: { 'C': [ (['Cerial'], 'node-B', False) ], 'F': [ (['Frühstücksflocken'], 'node-B', True) ] } } } result = Node.index_data self.assertEqual(result, expected) def test_complex(self): """Multiple nodes""" self.maxDiff = None reader = Reader(self.input_dir) reader.read_org_files() expected = { Node.IDX_ALL: { 'C': [ (['Cerial'], 'node-B', False) ], 'D': [ (['Dr. Doom'], 'sub-C', True) ], 'F': [ (['Frühstücksflocken'], 'node-B', True) ], 'H': [ (['Heading Sub-A'], 'sub-A', False), (['Heading Sub-C'], 'sub-C', False), ], 'N': [ (['Node-A'], 'node-A', False), (['Node-C'], 'node-C', False), (['Node-D'], 'node-D', False), (['Node-E'], 'node-E', False), (['Node-F'], 'node-F', False), ] }, Node.IDX_FILETAG: { Node.IDX_NO_TAG: { 'C': [ (['Cerial'], 'node-B', False) ], 'D': [ (['Dr. Doom'], 'sub-C', True) ], 'F': [ (['Frühstücksflocken'], 'node-B', True) ], 'H': [ (['Heading Sub-A'], 'sub-A', False), (['Heading Sub-C'], 'sub-C', False), ], 'N': [ (['Node-A'], 'node-A', False), (['Node-C'], 'node-C', False), ] }, 'Wiki': { 'N': [ (['Node-D'], 'node-D', False), (['Node-E'], 'node-E', False), ] }, 'Person': { 'N': [ (['Node-D'], 'node-D', False), (['Node-F'], 'node-F', False), ] }, } } result = Node.index_data self.assertEqual(result, expected) hyperorg/tests/test_textruns.py000066400000000000000000000356271522421240700174000ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under # GNU General Public License v3 (GPLv3). See LICENSES directory # or go to . """Tests about runs""" import unittest import logging from hyperorg import textruns class TestRun(unittest.TestCase): """Runs""" # pylint: disable=missing-function-docstring class ConcreteRun(textruns.Run): """A concrete but simple run just for testing""" # pylint: disable=too-few-public-methods def __init__(self, count): runs = [f'run {i}' for i in range(count)] super().__init__(runs=runs) def test_abstract(self): with self.assertRaises(TypeError): textruns.Run() def test_len(self): sut = __class__.ConcreteRun(7) self.assertEqual(len(sut), 7) def test_get_item(self): sut = __class__.ConcreteRun(7) self.assertEqual(sut[0], 'run 0') self.assertEqual(sut[5], 'run 5') def test_get_item_out_of_index(self): sut = __class__.ConcreteRun(7) with self.assertRaises(IndexError): _ = sut[7] def test_equal(self): runa = __class__.ConcreteRun(2) runb = __class__.ConcreteRun(2) self.assertTrue(runa == runb) class TestMarkup(unittest.TestCase): """Markup""" # pylint: disable=missing-function-docstring def test_init(self): sut = textruns.Markup( 'marked', textruns.Markup.Type.IMPORTANT ) self.assertEqual(len(sut), 1) self.assertEqual(sut[0], 'marked') self.assertEqual(sut.markup_type, textruns.Markup.Type.IMPORTANT) def test_nested_markup(self): """Markup with markup in it.""" # This is an italic (emphasized) markup with one bold (important) word # in it. sut = textruns.Markup( 'foo *important* bar', textruns.Token.EMPHASIZED ) self.assertEqual(len(sut), 3) self.assertEqual(sut[0], 'foo ') self.assertEqual(sut[1][0], 'important') self.assertEqual(sut[1].markup_type, textruns.Markup.Type.IMPORTANT) self.assertEqual(sut[2], ' bar') def test_link_inside(self): """Link inside the markup""" sut = textruns.Markup( 'foo [[id:123][geordie]] bar', textruns.Token.UNDERLINED ) self.assertEqual(len(sut), 3) self.assertEqual(sut[0], 'foo ') self.assertEqual(sut[1][0], 'geordie') self.assertEqual(sut[1].link_type, textruns.Link.Type.ORGID) self.assertEqual(sut[1].target, '123') self.assertEqual(sut[2], ' bar') def test_equal(self): sut_a = textruns.Markup('foobar', textruns.Markup.Type.IMPORTANT) sut_b = textruns.Markup('foobar', textruns.Markup.Type.IMPORTANT) sut_c = textruns.Markup('foobar', textruns.Markup.Type.UNDERLINED) self.assertTrue(sut_a == sut_b) self.assertFalse(sut_a == sut_c) class TestLink(unittest.TestCase): """Test Link class.""" def test_from_label(self): """Construct from label. Be aware that the opening ``[[`` and closing ``]]`` are removed before the Link run is initialized. That re-movement happens on the layer of the `element` module. """ link = textruns.Link.from_org_string('id:target][label') self.assertEqual(link[0], 'label') self.assertEqual(link.target, 'target') self.assertEqual(link.link_type, textruns.Link.Type.ORGID) def test_init_target_only(self): """No label""" link = textruns.Link.from_org_string('id:target') self.assertEqual(len(link), 0) self.assertEqual(link.target, 'target') self.assertEqual(link.link_type, textruns.Link.Type.ORGID) def test_init_all_types(self): """Sub testing all available link types""" test_data = { link_type: f'{link_type.value}:foo' for link_type in textruns.Link.Type } test_data[textruns.Link.Type.NONE] \ = test_data[textruns.Link.Type.NONE].replace(':', '') test_data[textruns.Link.Type.HTTPS] \ = test_data[textruns.Link.Type.HTTPS].replace(':', '://') for link_type, link in test_data.items(): sut = textruns.Link.from_org_string(link) self.assertEqual(sut.link_type, link_type) def test_case_insensitive_types(self): """Upper or lower case doesn't matter when recognizing link type""" test_data = { textruns.Link.Type.ORGID: ( 'ID:', 'id:', 'Id:', 'iD:'), textruns.Link.Type.HTTPS: ( 'https://', 'HTTPS://', 'Https://', 'HTtPs://', 'httPs://'), textruns.Link.Type.FILE: ( 'fIle:', 'fiLE:', 'FILE:', 'file:') } # pylint: disable-next=consider-using-dict-items test_data = [(k, v) for k in test_data for v in test_data[k]] for link_type, link_prefix in test_data: with self.subTest((link_type, link_prefix)): sut = textruns.Link.from_org_string(f'{link_prefix}foobar') self.assertEqual(sut.link_type, link_type) def test_no_type_links(self): """Links without types are OK. """ link = textruns.Link.from_org_string('foo') self.assertEqual(link.link_type, textruns.Link.Type.NONE) def test_equal(self): """Equal operator""" sut_a = textruns.Link.from_org_string('foo][bar') sut_b = textruns.Link.from_org_string('foo][bar') sut_c = textruns.Link.from_org_string('id:foo][bar') sut_d = textruns.Link.from_org_string('fxx][bar') self.assertTrue(sut_a == sut_b) self.assertFalse(sut_a == sut_c) self.assertFalse(sut_a == sut_d) def test_type_target_labels(self): """Recognition of type, targets and labels at once""" test_data = ( ('id:foo][bar', ('id', 'foo', 'bar')), ('https:foo][bar', ('https', 'foo', 'bar')), ('foo][bar', (None, 'foo', 'bar')), ('id:foo', ('id', 'foo', None)), ('id:foo][bar:extra', ('id', 'foo', 'bar:extra')), ('foo][bar:extra', (None, 'foo', 'bar:extra')), ) for sut, expect in test_data: with self.subTest(sut): # pylint: disable=protected-access self.assertEqual( textruns.Link._REX_TYPE_TARGET_LABEL.match(sut).groups(), expect ) def test_unknown_type_with_label(self): """Link with in an unknown type (protocol) and a label.""" with self.assertLogs(level=logging.WARNING) as cm_log: sut = textruns.Link.from_org_string( 'unknowntype:rainbowtarget][colorfullabel' ) self.assertEqual(1, len(cm_log.output)) self.assertRegex( cm_log.output[0], r'.*Link type.*not supported.*' ) self.assertEqual(sut.link_type, textruns.Link.Type.UNKNOWN) # First run is the original type self.assertEqual(sut[0], 'unknowntype') self.assertEqual(sut[1], 'colorfullabel') self.assertEqual(len(sut), 2) self.assertEqual(sut.target, 'rainbowtarget') def test_unknown_type_without_label(self): """Link with in an unknown type (protocol).""" with self.assertLogs(level=logging.WARNING) as cm_log: sut = textruns.Link.from_org_string('unknowntype:rainbowtarget') self.assertEqual(1, len(cm_log.output)) self.assertRegex( cm_log.output[0], r'.*Link type.*not supported.*' ) self.assertEqual(sut.link_type, textruns.Link.Type.UNKNOWN) # First run is the original type self.assertEqual(sut[0], 'unknowntype') self.assertEqual(len(sut), 1) self.assertEqual(sut.target, 'rainbowtarget') class TestImage(unittest.TestCase): """Image""" # pylint: disable=missing-function-docstring def test_target(self): sut = textruns.Image('img.png') self.assertEqual(sut.target, 'img.png') def test_alternative(self): sut = textruns.Image('img.png') self.assertEqual(sut.alternative, 'img.png') def test_alternative_https(self): sut = textruns.Image('https://foo.bar/img.png') self.assertEqual(sut.alternative, 'img.png') def test_leading_folder_seperator(self): sut = textruns.Image('/img.png') self.assertEqual(sut.target, '/img.png') class TestInlineMath(unittest.TestCase): """Test Math class.""" def test_init_defaults(self): """Default init call.""" sut = textruns.InlineMath('e = mc^{2}') self.assertEqual(len(sut), 1) self.assertEqual(sut[0], 'e = mc^{2}') class DontTouchVerbatimOrCode(unittest.TestCase): """Test special rule that content enclosed by verbatim or code markups (``=`` and ``~``) should not be parsed or cut. """ # pylint: disable=missing-function-docstring def test_markup_class(self): """Class `textruns.Markup` shouldn't parse verbatim/code.""" for text in ['[[id:123][geordie]]', '*don\'t parse it*']: for markup_type in [textruns.Markup.Type.CODE, textruns.Markup.Type.VERBATIM]: markup = textruns.Markup(text, markup_type) self.assertEqual(len(markup.runs), 1) self.assertEqual(str(markup.runs[0]), text) def test_ignore_links(self): """Don't treat links when enclosded by verbatim or code markup.""" for markup_type in [textruns.Markup.Type.CODE, textruns.Markup.Type.VERBATIM]: text = 'Foo {m}very [[id:1234][important]] link{m} bar' \ .format(m=markup_type.value) result = textruns.cut_into_runs(text) self.assertEqual(len(result), 3) self.assertIsInstance(result[1], textruns.Markup) self.assertEqual(len(result[1].runs), 1) self.assertEqual(str(result[1].runs[0]), 'very [[id:1234][important]] link') self.assertEqual(result[1].markup_type, markup_type) def test_ignore_markup(self): for outer_type in [textruns.Markup.Type.CODE, textruns.Markup.Type.VERBATIM]: for markup_type in textruns.Markup.Type: if markup_type == outer_type: continue text = 'Foo {vc}with {m}in-markup{m} bar{vc} fin.'.format( vc=outer_type.value, m=markup_type.value) result = textruns.cut_into_runs(text) self.assertEqual(len(result), 3) self.assertIsInstance(result[1], textruns.Markup) self.assertEqual(len(result[1].runs), 1) self.assertEqual( str(result[1].runs[0]), (f'with {markup_type.value}in-markup' f'{markup_type.value} bar')) class NestedLinkAndMarkup(unittest.TestCase): """Test special situation with nested markup/links.""" # pylint: disable=missing-function-docstring def test_markup_in_link(self): text = 'Foo [[id:1234][This is *important*]] bar' result = textruns.cut_into_runs(text) self.assertEqual(len(result), 3) self.assertEqual(result[0], 'Foo ') self.assertEqual(len(result[1]), 2) self.assertEqual(result[1][0], 'This is ') self.assertEqual(result[1].link_type, textruns.Link.Type.ORGID) self.assertEqual(result[1][1][0], 'important') self.assertEqual(result[1][1].markup_type, textruns.Markup.Type.IMPORTANT) self.assertEqual(result[2], ' bar') def test_link_in_markup(self): text = 'Foo *very [[id:1234][important]] link* bar' result = textruns.cut_into_runs(text) self.assertEqual(len(result), 3) self.assertEqual(result[0], 'Foo ') self.assertEqual(len(result[1]), 3) self.assertEqual(result[1][0], 'very ') self.assertEqual(result[1][1][0], 'important') self.assertEqual(result[1][1].target, '1234') self.assertEqual(result[1][1].link_type, textruns.Link.Type.ORGID) self.assertEqual(result[1][2], ' link') self.assertEqual(result[1].markup_type, textruns.Markup.Type.IMPORTANT) self.assertEqual(result[2], ' bar') class ImagesAndLinks(unittest.TestCase): """Images and Links""" def test_inline_image(self): """Simple inline image.""" sut = 'Foo [[img.png]] bar' result = textruns.cut_into_runs(sut) self.assertEqual(len(result), 3) self.assertIsInstance(result[1], textruns.Image) image = result[1] self.assertEqual(image.target, 'img.png') def test_inline_image_file_type(self): """Inline image with "file:" prefix""" sut = 'Foo [[file:img.png]] bar' result = textruns.cut_into_runs(sut) self.assertEqual(len(result), 3) self.assertEqual(result[1].target, 'img.png') def test_inline_linked_image_locale_file(self): """Linked inline image with "file:" prefix""" sut = 'Foo [[linktarget.png][file:labelimage.png]] bar' result = textruns.cut_into_runs(sut) self.assertEqual(len(result), 3) link = result[1] self.assertIsInstance(link, textruns.Link) self.assertEqual(link.link_type, textruns.Link.Type.NONE) self.assertEqual(link.target, 'linktarget.png') # The "label" of the link is an image image = link[0] self.assertIsInstance(image, textruns.Image) self.assertEqual(image.target, 'labelimage.png') def test_inline_linked_image_https(self): """Linked inline image with "https:" prefix""" sut = 'Foo [[linktarget.png][https://labelimage.png]] bar' result = textruns.cut_into_runs(sut) self.assertEqual(len(result), 3) link = result[1] self.assertIsInstance(link, textruns.Link) self.assertEqual(link.link_type, textruns.Link.Type.NONE) self.assertEqual(link.target, 'linktarget.png') # The "label" of the link is an image image = link[0] self.assertIsInstance(image, textruns.Image) self.assertEqual(image.target, 'https://labelimage.png') def test_not_a_linked_image(self): """Just a linked text.""" # Images as labels are only recoginied with file: or https:// prefix. # Otherwise it is just a string. sut = 'Foo [[linktarget.png][labelimage.png]] bar' result = textruns.cut_into_runs(sut) link = result[1] label = link[0] self.assertEqual(label, 'labelimage.png') hyperorg/tests/test_textruns_token_cutting.py000066400000000000000000000314431522421240700223250ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under # GNU General Public License v3 (GPLv3). See LICENSES directory # or go to . """Tests about runs""" import unittest from hyperorg import textruns class CutIntoRuns(unittest.TestCase): """Test conversion from text to runs. Here with `textruns.cut_into_runs()` the combination of the three functions `textruns._token_positions()`, `textruns._token_pairs()` and `textruns._runs_from_token_pairs()` is tested. """ # pylint: disable=missing-function-docstring def test_text_only(self): """One simple text run.""" text = 'Another one got caught today' result = textruns.cut_into_runs(text) self.assertEqual(len(result), 1) self.assertIsInstance(result[0], str) def test_empty_string(self): """Empty strings. This is e.g. useful with list elements where empty list entries are allowed. """ result = textruns.cut_into_runs('') self.assertEqual(len(result), 1) self.assertEqual(result[0], '') def test_verbatim_only(self): sut = textruns.cut_into_runs('=--foo=') self.assertEqual(len(sut), 1) self.assertEqual( sut[0], textruns.Markup('--foo', textruns.Markup.Type.VERBATIM) ) def test_markup_combined(self): """Bigger text with multiple markup in it.""" text = ( '+Governments+ of the *Industrial World*, you weary =giants= of ' 'flesh and steel, I come from _Cyberspace_, the new home of Mind. ' 'On /behalf of the future/, I ask you of the past to leave us ' 'alone. You are ~not welcome~ among us. You have no sovereignty ' 'where we gather.' ) result = textruns.cut_into_runs(text) self.assertEqual(len(result), 12) expected_parts = { 1: ' of the ', 3: ', you weary ', 5: ' of flesh and steel, I come from ', 7: ', the new home of Mind. On ', 9: ', I ask you of the past to leave us alone. You are ', 11: ' among us. You have no sovereignty where we gather.' } for idx, exp in expected_parts.items(): self.assertEqual(str(result[idx]), exp) expected_parts = { 0: 'Governments', 2: 'Industrial World', 4: 'giants', 6: 'Cyberspace', 8: 'behalf of the future', 10: 'not welcome', } for idx, exp in expected_parts.items(): self.assertEqual(str(result[idx].runs[0]), exp) marker_at_index = [ (0, textruns.Markup.Type.STRIKETHROUGH), (2, textruns.Markup.Type.IMPORTANT), (4, textruns.Markup.Type.VERBATIM), (6, textruns.Markup.Type.UNDERLINED), (8, textruns.Markup.Type.EMPHASIZED), (10, textruns.Markup.Type.CODE) ] for idx, markup_type in marker_at_index: self.assertEqual(result[idx].markup_type, markup_type) def test_links_combined_two(self): test = ( 'This is [[id:1234][World]] and [[roam:Buhtz][Me]] ende' ) result = textruns.cut_into_runs(test) self.assertEqual(len(result), 5) self.assertEqual(result[0], 'This is ') self.assertEqual(result[1][0], 'World') self.assertEqual(result[1].target, '1234') self.assertEqual(result[2], ' and ') self.assertEqual(result[3][0], 'Me') self.assertEqual(result[3].target, 'Buhtz') self.assertEqual(result[4], ' ende') def test_links_combined_all(self): test = ( 'This is [[https://world.me][World]] and [[roam:Buhtz][Me]] ' 'or just [[roam:Buhtz]]. Also try [[id:12345][with label]] or ' 'that [[https://out.there]] somewhere.' ) result = textruns.cut_into_runs(test) self.assertEqual(len(result), 11) self.assertEqual(result[1][0], 'World') self.assertEqual(result[1].target, 'world.me') self.assertEqual(result[3][0], 'Me') self.assertEqual(result[3].target, 'Buhtz') self.assertEqual(len(result[5]), 0) self.assertEqual(result[5].target, 'Buhtz') self.assertEqual(result[7][0], 'with label') self.assertEqual(result[7].target, '12345') self.assertEqual(len(result[9]), 0) self.assertEqual(result[9].target, 'out.there') def test_links_and_markup_neighbors(self): test = 'you _weary_ [[https://big.com][giants]]' result = textruns.cut_into_runs(test) self.assertEqual(len(result), 4) self.assertEqual(result[0], 'you ') self.assertEqual(result[1][0], 'weary') self.assertEqual(result[1].markup_type, textruns.Markup.Type.UNDERLINED) self.assertEqual(result[2], ' ') self.assertEqual(result[3][0], 'giants') self.assertEqual(result[3].target, 'big.com') self.assertEqual(result[3].link_type, textruns.Link.Type.HTTPS) def test_links_variants(self): test_data = [ ( # orgid 'This [[id:0123456789][is an OrgId link]] test.', { 1: [textruns.Link.Type.ORGID, '0123456789', 'is an OrgId link'], }, ), ( # roam with labels 'This [[roam:0123456789][is a Roam link]] test.', { 1: [textruns.Link.Type.ROAM, '0123456789', 'is a Roam link'], }, ), ( # roam without labels 'This [[roam:Buhtz]] test.', { 1: [textruns.Link.Type.ROAM, 'Buhtz', None], }, ), ( # https with label 'This [[https://my.world][Buhtz]] test.', { 1: [textruns.Link.Type.HTTPS, 'my.world', 'Buhtz'], }, ), ( # two orgid 'This [[id:01234][Foo]] and [[id:5678][bar]].', { 1: [textruns.Link.Type.ORGID, '01234', 'Foo'], 3: [textruns.Link.Type.ORGID, '5678', 'bar'], }, ), ] for text, links in test_data: result = textruns.cut_into_runs(text) for idx, lnk in links.items(): kind, target, label = lnk self.assertEqual(result[idx].link_type, kind) self.assertEqual(result[idx].target, target) if label: self.assertEqual(result[idx][0], label) else: self.assertEqual(len(result[idx]), 0) def test_links_label_newline(self): text = 'This [[id:123][is an\nOrgId link]] test.' result = textruns.cut_into_runs(text) self.assertEqual(result[1][0], 'is an\nOrgId link') def test_url_with_slash_at_end(self): """If there is a slash at the end of an URL it was (see #64) interpreted as emphasized inline markup. """ text = 'Foo [[https://my.world/]] bar' result = textruns.cut_into_runs(text) self.assertEqual(result[0], 'Foo ') self.assertEqual(len(result[1]), 0) self.assertEqual(result[1].target, 'my.world/') self.assertEqual(result[2], ' bar') def test_multiple_links_and_markup_all(self): test = ( '*Governments* of the [[roam:Industrial World][IW]], you _weary_' ' [[https://big.com][giants]] /of/ [[id:1854][flesh]] and ' '=steel=, +I+ come from [[roam:Cyberspace]], the ~new home~ of ' '[[https://Mind.org]]. On behalf of the future' ) result = textruns.cut_into_runs(test) self.assertEqual(len(result), 22) # the key here is the index in ``result`` check_links = { 2: ('Industrial World', 'IW', textruns.Link.Type.ROAM), 6: ('big.com', 'giants', textruns.Link.Type.HTTPS), 10: ('1854', 'flesh', textruns.Link.Type.ORGID), 16: ('Cyberspace', None, textruns.Link.Type.ROAM), 20: ('Mind.org', None, textruns.Link.Type.HTTPS) } for idx_key, links in check_links.items(): target, label, kind = links self.assertEqual(result[idx_key].target, target) if label: self.assertEqual(result[idx_key][0], label) else: self.assertEqual(len(result[idx_key]), 0) self.assertEqual(result[idx_key].link_type, kind) check_markup = { 0: ('Governments', textruns.Markup.Type.IMPORTANT), 4: ('weary', textruns.Markup.Type.UNDERLINED), 8: ('of', textruns.Markup.Type.EMPHASIZED), 12: ('steel', textruns.Markup.Type.VERBATIM), 14: ('I', textruns.Markup.Type.STRIKETHROUGH), 18: ('new home', textruns.Markup.Type.CODE) } for idx_key, markup in check_markup.items(): text, kind = markup self.assertEqual(len(result[idx_key]), 1) self.assertEqual(result[idx_key][0], text) self.assertEqual(result[idx_key].markup_type, kind) def test_markup_without_prefix_suffix(self): sut = textruns.cut_into_runs('+Government+') self.assertEqual(len(sut), 1) self.assertEqual(sut[0][0], 'Government') self.assertEqual( sut[0].markup_type, textruns.Markup.Type.STRIKETHROUGH ) def test_markup_prefix_suffix(self): result = textruns.cut_into_runs('foo +Government+ bar') self.assertEqual(len(result), 3) self.assertEqual(str(result[0]), 'foo ') self.assertEqual(str(result[2]), ' bar') self.assertIsInstance(result[1], textruns.Markup) markup = result[1] self.assertEqual(str(markup.runs[0]), 'Government') self.assertEqual( markup.markup_type, textruns.Markup.Type.STRIKETHROUGH ) def test_markup_variant_blanks(self): result = textruns.cut_into_runs(' +Government+ ') self.assertEqual(len(result), 3) self.assertEqual(str(result[0]), ' ') self.assertEqual(str(result[2]), ' ') self.assertEqual(str(result[1].runs[0]), 'Government') self.assertEqual(result[1].markup_type, textruns.Markup.Type.STRIKETHROUGH) def test_markup_two(self): for markup_type in textruns.Markup.Type: text = 'Governments {m}of{m} the {m}Industrial{m} World' \ .format(m=markup_type.value) result = textruns.cut_into_runs(text) self.assertEqual(len(result), 5, markup_type) self.assertEqual(str(result[0]), 'Governments ', markup_type) self.assertEqual(str(result[1].runs[0]), 'of', markup_type) self.assertEqual(str(result[2]), ' the ', markup_type) self.assertEqual(str(result[3].runs[0]), 'Industrial', markup_type) self.assertEqual(str(result[4]), ' World', markup_type) def test_markup_two_without_pre_suffix(self): for markup_type in textruns.Markup.Type: text = '{m}Governments{m} of {m}the{m} Industrial {m}World{m}' \ .format(m=markup_type.value) result = textruns.cut_into_runs(text) self.assertEqual(len(result), 5, markup_type) self.assertEqual(str(result[0].runs[0]), 'Governments', markup_type) self.assertEqual(str(result[1]), ' of ', markup_type) self.assertEqual(str(result[2].runs[0]), 'the', markup_type) self.assertEqual(str(result[3]), ' Industrial ', markup_type) self.assertEqual(str(result[4].runs[0]), 'World', markup_type) def test_markup_entangled(self): """First wins.""" text = '/italics ~code/ verbatim~' result = textruns.cut_into_runs(text) self.assertEqual(len(result), 2) self.assertEqual(str(result[0].runs[0]), 'italics ~code') self.assertEqual(str(result[1]), ' verbatim~') def test_unsecure_http(self): """HTTP links are not secure but will be parsed. See #29 about an improvement of that behavior unsafe (because of HTTP-only) hyperlinks not parsed """ text = 'This [[http://foo.bar][foobar]] is not safe.' result = textruns.cut_into_runs(text) self.assertEqual(len(result), 3) self.assertEqual(result[1][0], 'foobar') self.assertEqual(result[1].target, 'foo.bar') self.assertEqual(result[1].link_type, textruns.Link.Type.HTTP) hyperorg/tests/test_textruns_tokens.py000066400000000000000000000311241522421240700207470ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2023 Christian BUHTZ # SPDX-FileCopyrightText: © 2025 Michael Olie # # SPDX-License-Identifier: GPL-3.0-only # # This file is part of the program "Hyperorg" which is released under # GNU General Public License v3 (GPLv3). See LICENSES directory # or go to . # pylint: disable=too-many-lines """Tests about tokens and creating runs based on tokens.""" import unittest from hyperorg import textruns class TokenPositions(unittest.TestCase): """Test `textruns._token_position()`""" # pylint: disable=missing-function-docstring,protected-access def test_token_close(self): self.assertEqual( textruns._token_positions('Foo* bar'), [(3, textruns.Token.IMPORTANT, textruns.TokenState.CLOSE)] ) def test_token_open(self): self.assertEqual( textruns._token_positions('Foo *bar'), [(4, textruns.Token.IMPORTANT, textruns.TokenState.OPEN)] ) def test_all_tokens(self): """All kinds of tokens.""" for token in textruns.Token: if token == textruns.Token.LINK: text = 'Foo [[marku]] bar' expect = [ (4, token, textruns.TokenState.OPEN), (11, token, textruns.TokenState.CLOSE) ] # elif token == textruns.Token.MATH_DOLLAR: # text = 'Foo $marku$ bar' # expect = [ # (4, token, textruns.TokenState.OPEN), # (10, token, textruns.TokenState.CLOSE) # ] elif token == textruns.Token.MATH_PARENS: text = r'Foo \(marku\) bar' expect = [ (4, token, textruns.TokenState.OPEN), (11, token, textruns.TokenState.CLOSE) ] # elif token == textruns.Token.MATH_DBL_DOLLAR: # text = r'Foo $$marku$$ bar' # expect = [ # (4, token, textruns.TokenState.OPEN), # (12, token, textruns.TokenState.CLOSE) # ] # elif token == textruns.Token.MATH_BRACKETS: # text = r'Foo \[marku\] bar' # expect = [ # (4, token, textruns.TokenState.OPEN), # (11, token, textruns.TokenState.CLOSE) # ] else: text = f'Foo {token.value}marku{token.value} bar' expect = [ (4, token, textruns.TokenState.OPEN), (10, token, textruns.TokenState.CLOSE) ] result = textruns._token_positions(text) self.assertEqual(result, expect) def test_side_by_side(self): """One markup after the other.""" text = 'Foo ~code~ and /italic/ bar' self.assertEqual( textruns._token_positions(text), [ (4, textruns.Token.CODE, textruns.TokenState.OPEN), (9, textruns.Token.CODE, textruns.TokenState.CLOSE), (15, textruns.Token.EMPHASIZED, textruns.TokenState.OPEN), (22, textruns.Token.EMPHASIZED, textruns.TokenState.CLOSE), ] ) def test_entangled(self): """Entangled markup tokens. It doesn't matter at this point if this are correct pairs or not. """ text = '/Worf *Picard/ Diana*' self.assertEqual( textruns._token_positions(text), [ (0, textruns.Token.EMPHASIZED, textruns.TokenState.OPEN), (6, textruns.Token.IMPORTANT, textruns.TokenState.OPEN), (13, textruns.Token.EMPHASIZED, textruns.TokenState.CLOSE), (20, textruns.Token.IMPORTANT, textruns.TokenState.CLOSE), ] ) def test_enclosed(self): """Nested markup tokens.""" text = 'Frank /Burns *is* a/ fret.' self.assertEqual( textruns._token_positions(text), [ (6, textruns.Token.EMPHASIZED, textruns.TokenState.OPEN), (13, textruns.Token.IMPORTANT, textruns.TokenState.OPEN), (16, textruns.Token.IMPORTANT, textruns.TokenState.CLOSE), (19, textruns.Token.EMPHASIZED, textruns.TokenState.CLOSE), ] ) def test_link(self): """Link tokens.""" text = 'Giants [[id:1234][Foo]] Cyberspace' result = textruns._token_positions(text) self.assertEqual( result, [ (7, textruns.Token.LINK, textruns.TokenState.OPEN), (21, textruns.Token.LINK, textruns.TokenState.CLOSE) ] ) def test_none_token(self): """ Blanks after OPEN or before CLOSE is not allowed. It is not a token. """ self.assertEqual( textruns._token_positions(" / foobar /"), [] ) class TokenPairs(unittest.TestCase): """Test `textruns._token_pairs()`""" # pylint: disable=missing-function-docstring,protected-access def test_all_tokens(self): for token in textruns.Token: positions = [ (6, token, textruns.TokenState.OPEN), (20, token, textruns.TokenState.CLOSE)] expect = [(token, 6, 20)] self.assertEqual(textruns._token_pairs(positions), expect) def test_side_by_side(self): positions = [ (2, textruns.Token.EMPHASIZED, textruns.TokenState.OPEN), (6, textruns.Token.EMPHASIZED, textruns.TokenState.CLOSE), (9, textruns.Token.IMPORTANT, textruns.TokenState.OPEN), (14, textruns.Token.IMPORTANT, textruns.TokenState.CLOSE) ] self.assertEqual( textruns._token_pairs(positions), [ (textruns.Token.EMPHASIZED, 2, 6), (textruns.Token.IMPORTANT, 9, 14), ] ) def test_enclosed(self): """The encloused markup is deleted. The intention is to parse the inner content of the markup in another iteration. """ # The two inner tokens are ignored positions = [ (2, textruns.Token.EMPHASIZED, textruns.TokenState.OPEN), (9, textruns.Token.IMPORTANT, textruns.TokenState.OPEN), (14, textruns.Token.IMPORTANT, textruns.TokenState.CLOSE), (20, textruns.Token.EMPHASIZED, textruns.TokenState.CLOSE), ] self.assertEqual( textruns._token_pairs(positions), [ (textruns.Token.EMPHASIZED, 2, 20), ] ) def test_entangled(self): """First wins. The pairs are entangled like 'a /b *c/ d* e'. The first wins means that the emphasized is closed first and valid in that case. """ positions = [ (0, textruns.Token.EMPHASIZED, textruns.TokenState.OPEN), (6, textruns.Token.IMPORTANT, textruns.TokenState.OPEN), (13, textruns.Token.EMPHASIZED, textruns.TokenState.CLOSE), (20, textruns.Token.IMPORTANT, textruns.TokenState.CLOSE) ] self.assertEqual( textruns._token_pairs(positions), [(textruns.Token.EMPHASIZED, 0, 13)] ) def test_link(self): positions = [ (7, textruns.Token.LINK, textruns.TokenState.OPEN), (21, textruns.Token.LINK, textruns.TokenState.CLOSE) ] self.assertEqual( textruns._token_pairs(positions), [(textruns.Token.LINK, 7, 21)] ) class RunsFromTokenPairs(unittest.TestCase): """Test `textruns._runs_from_token_pairs()`""" # pylint: disable=missing-function-docstring,protected-access def test_markup_tokens(self): """ Testing all markup token types (excluding links and math). This test checks that all markup token types are handled correctly by `_runs_from_token_pairs`. The test uses a generic marker ('X') in the string, as the marker itself does not affect the parsing—only its position does. """ for token in textruns.Token: # Skip non-markup tokens non_markup_tokens = ( textruns.Token.LINK, textruns.Token.MATH_DOLLAR, textruns.Token.MATH_PARENS, # textruns.Token.MATH_DBL_DOLLAR, # textruns.Token.MATH_BRACKETS ) if token in non_markup_tokens: continue text = 'Riker XwithX Data' pairs = [(token, 6, 11)] result = textruns._runs_from_token_pairs(text, pairs) self.assertEqual(str(result[0]), 'Riker ') self.assertEqual(str(result[1].runs[0]), 'with') self.assertEqual(result[1].markup_type.value, token.value) self.assertEqual(result[2], ' Data') def test_link(self): text = 'Riker [[id:with]] Data' pairs = [(textruns.Token.LINK, 6, 15)] result = textruns._runs_from_token_pairs(text, pairs) self.assertEqual(result[0], 'Riker ') self.assertEqual(len(result[1]), 0) self.assertEqual(result[1].target, 'with') self.assertEqual(result[1].link_type, textruns.Link.Type.ORGID) self.assertEqual(result[2], ' Data') def test_inlinemath_dollars(self): """Test MATH_DOLLAR token handling.""" text = 'Riker $with$ Data' pairs = [(textruns.Token.MATH_DOLLAR, 6, 11)] result = textruns._runs_from_token_pairs(text, pairs) self.assertEqual(str(result[0]), 'Riker ') self.assertIsInstance(result[1], textruns.InlineMath) self.assertEqual(str(result[1].runs[0]), 'with') self.assertEqual(result[2], ' Data') def test_inlinemath_parens(self): """Test MATH_PARENS token handling.""" text = r'Riker \(with\) Data' pairs = [(textruns.Token.MATH_PARENS, 6, 12)] result = textruns._runs_from_token_pairs(text, pairs) self.assertEqual(str(result[0]), 'Riker ') self.assertIsInstance(result[1], textruns.InlineMath) self.assertEqual(str(result[1].runs[0]), 'with') self.assertEqual(result[2], ' Data') def test_inlinemath_dollar_with_complex_content(self): """Test MATH_DOLLAR token with complex content.""" text = 'The equation $x^2 + y^2 = z^2$ is famous' pairs = [(textruns.Token.MATH_DOLLAR, 13, 29)] result = textruns._runs_from_token_pairs(text, pairs) self.assertEqual(result[0], 'The equation ') self.assertIsInstance(result[1], textruns.InlineMath) self.assertEqual(str(result[1][0]), 'x^2 + y^2 = z^2') self.assertEqual(result[2], ' is famous') def test_inlinemath_and_markup_tokens(self): """Test mixing math tokens with markup tokens""" text = ( r'The *important* equation $E=mc^2$ and \(F=ma\) are fundamental' ) pairs = [ (textruns.Token.IMPORTANT, 4, 14), (textruns.Token.MATH_DOLLAR, 25, 32), (textruns.Token.MATH_PARENS, 38, 44), ] result = textruns._runs_from_token_pairs(text, pairs) self.assertEqual(str(result[0]), 'The ') # Important markup self.assertIsInstance(result[1], textruns.Markup) self.assertEqual(result[1][0], 'important') self.assertEqual(result[1].markup_type, textruns.Markup.Type.IMPORTANT) self.assertEqual(result[2], ' equation ') # Inline (Dollar) math self.assertIsInstance(result[3], textruns.InlineMath) self.assertEqual(str(result[3][0]), 'E=mc^2') self.assertEqual(result[4], ' and ') # Inline (Parens) math self.assertIsInstance(result[5], textruns.InlineMath) self.assertEqual(str(result[5][0]), 'F=ma') self.assertEqual(result[6], ' are fundamental') def test_side_by_side(self): text = 'Foo ~code~ and /italic/ bar' pairs = [ (textruns.Token.CODE, 4, 9), (textruns.Token.EMPHASIZED, 15, 22), ] result = textruns._runs_from_token_pairs(text, pairs) # text runs self.assertEqual(str(result[0]), 'Foo ') self.assertEqual(str(result[2]), ' and ') self.assertEqual(str(result[4]), ' bar') # markups self.assertEqual(str(result[1].runs[0]), 'code') self.assertEqual(str(result[3].runs[0]), 'italic') self.assertEqual( result[1].markup_type, textruns.Markup.Type.CODE) self.assertEqual( result[3].markup_type, textruns.Markup.Type.EMPHASIZED)