From 8b5cd370ea523b266fccf5626382c71af13f8bf4 Mon Sep 17 00:00:00 2001 From: Tyler Perkins Date: Sat, 1 Aug 2026 21:03:35 -0400 Subject: [PATCH] Initial commit --- .dockerignore | 11 + .env.example | 17 + .gitea/workflows/ci.yml | 93 +++ .gitignore | 18 + Dockerfile | 27 + LICENSE | 674 ++++++++++++++++++ README.md | 214 ++++++ SPEC.md | 715 ++++++++++++++++++++ docker-compose.prod.yml | 72 ++ docker-compose.yml | 25 + docker/mdcaldav.toml | 25 + docker/radicale.conf | 21 + pyproject.toml | 32 + reverse-proxy/Caddyfile.example | 30 + reverse-proxy/nginx.conf.example | 54 ++ src/mdcaldav/__init__.py | 0 src/mdcaldav/cli.py | 173 +++++ src/mdcaldav/collections.py | 57 ++ src/mdcaldav/config.py | 165 +++++ src/mdcaldav/globs.py | 57 ++ src/mdcaldav/ical.py | 199 ++++++ src/mdcaldav/index.py | 431 ++++++++++++ src/mdcaldav/model.py | 74 ++ src/mdcaldav/parser.py | 286 ++++++++ src/mdcaldav/storage.py | 286 ++++++++ src/mdcaldav/watcher.py | 82 +++ src/mdcaldav/writer.py | 274 ++++++++ tests/__init__.py | 0 tests/conftest.py | 32 + tests/fixtures/vault/daily/2026-01-05.md | 33 + tests/fixtures/vault/daily/2026-01-06.md | 24 + tests/fixtures/vault/daily/2026-01-07.md | 12 + tests/fixtures/vault/projects/irrigation.md | 18 + tests/fixtures/vault/reference/glossary.md | 6 + tests/test_caldav_integration.py | 218 ++++++ tests/test_globs.py | 103 +++ tests/test_ical.py | 128 ++++ tests/test_index.py | 190 ++++++ tests/test_parser.py | 193 ++++++ tests/test_watcher_cli.py | 108 +++ tests/test_writer.py | 285 ++++++++ 41 files changed, 5462 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker/mdcaldav.toml create mode 100644 docker/radicale.conf create mode 100644 pyproject.toml create mode 100644 reverse-proxy/Caddyfile.example create mode 100644 reverse-proxy/nginx.conf.example create mode 100644 src/mdcaldav/__init__.py create mode 100644 src/mdcaldav/cli.py create mode 100644 src/mdcaldav/collections.py create mode 100644 src/mdcaldav/config.py create mode 100644 src/mdcaldav/globs.py create mode 100644 src/mdcaldav/ical.py create mode 100644 src/mdcaldav/index.py create mode 100644 src/mdcaldav/model.py create mode 100644 src/mdcaldav/parser.py create mode 100644 src/mdcaldav/storage.py create mode 100644 src/mdcaldav/watcher.py create mode 100644 src/mdcaldav/writer.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/vault/daily/2026-01-05.md create mode 100644 tests/fixtures/vault/daily/2026-01-06.md create mode 100644 tests/fixtures/vault/daily/2026-01-07.md create mode 100644 tests/fixtures/vault/projects/irrigation.md create mode 100644 tests/fixtures/vault/reference/glossary.md create mode 100644 tests/test_caldav_integration.py create mode 100644 tests/test_globs.py create mode 100644 tests/test_ical.py create mode 100644 tests/test_index.py create mode 100644 tests/test_parser.py create mode 100644 tests/test_watcher_cli.py create mode 100644 tests/test_writer.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d53a16 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +.git/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ +data/ +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c11d1bc --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Absolute path to the vault to serve. With Syncthing, this is the synced +# folder itself. Point it at a COPY until you trust write-back — this service +# rewrites the files it finds. +VAULT_PATH=/home/you/notes + +# Write-back runs as this uid/gid. It MUST match the owner of VAULT_PATH, +# otherwise writes fail or Syncthing fights over file permissions. +# VAULT_UID=$(id -u) VAULT_GID=$(id -g) +VAULT_UID=1000 +VAULT_GID=1000 + +# Where to publish the CalDAV port for the reverse-proxy machine to reach. +# TLS terminates at that proxy, so this listener is plain HTTP — set +# BIND_ADDRESS to this host's LAN address rather than leaving it on all +# interfaces, and firewall the port to the proxy's IP. +BIND_ADDRESS=0.0.0.0 +BIND_PORT=5233 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..97e3375 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: git.clortox.com + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Tests run inside the same Python version the image ships, without + # depending on what the runner happens to have installed. + - name: Run test suite + run: | + docker run --rm -v "$PWD:/src" -w /src python:3.14-slim \ + sh -c 'pip install --no-cache-dir -e ".[dev]" && python -m pytest -q' + + image: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Derive image name and tags + id: meta + run: | + set -eu + image="$REGISTRY/$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')" + tags="$image:${GITHUB_SHA:0:7}" + + case "$GITHUB_REF" in + refs/heads/main) + tags="$tags,$image:latest" + ;; + refs/tags/v*) + version="${GITHUB_REF#refs/tags/v}" + tags="$tags,$image:$version" + # v1.2.3 also moves the 1.2 and 1 pointers. + case "$version" in + *.*.*) + tags="$tags,$image:${version%.*},$image:${version%%.*}" + ;; + esac + ;; + esac + + echo "image=$image" >> "$GITHUB_OUTPUT" + echo "tags=$tags" >> "$GITHUB_OUTPUT" + echo "Tagging: $tags" + + - name: Log in to the Gitea container registry + run: | + echo "${{ secrets.GITEA_TOKEN }}" \ + | docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin + + - name: Build and push + run: | + set -eu + args="" + for tag in $(echo "${{ steps.meta.outputs.tags }}" | tr ',' ' '); do + args="$args -t $tag" + done + # shellcheck disable=SC2086 + docker build $args \ + --label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + --label "org.opencontainers.image.revision=$GITHUB_SHA" \ + . + for tag in $(echo "${{ steps.meta.outputs.tags }}" | tr ',' ' '); do + docker push "$tag" + done + + - name: Smoke-test the pushed image + run: | + set -eu + image="${{ steps.meta.outputs.image }}:${GITHUB_SHA:0:7}" + mkdir -p /tmp/smoke/daily + printf -- '- [ ] [#A] ci smoke task\n' > /tmp/smoke/daily/notes.md + docker run --rm -v /tmp/smoke:/vault "$image" \ + mdcaldav --vault /vault scan | grep -q 'ci smoke task' + echo "image runs and parses a vault" + + - name: Log out + if: always() + run: docker logout "$REGISTRY" || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1b80a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ + +# Runtime state and secrets +/data/ +docker/users +.env + +# Never commit real notes; fixtures must stay synthetic. +/vault/ +/notes/ +/testvault/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e0df3ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 +FROM python:3.14-slim AS base + +# bcrypt is needed for htpasswd auth; kept out of the default deps because the +# CLI does not need it. +RUN pip install --no-cache-dir bcrypt + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src ./src +RUN pip install --no-cache-dir . + +# The vault is bind-mounted at /vault; the sidecar index lives at /data so it is +# never written inside the user's notes. +ENV MDCALDAV_CONFIG=/config/mdcaldav.toml \ + PYTHONUNBUFFERED=1 + +EXPOSE 5232 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; \ +sys.exit(0 if urllib.request.urlopen('http://localhost:5232/', timeout=4).status < 500 else 1)" \ + || exit 1 + +# Stock Radicale loads our storage plugin by name. Running Radicale directly +# (rather than `mdcaldav serve`) keeps every Radicale option available. +CMD ["radicale", "--config", "/config/radicale.conf"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cec4a4d --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# markdown-to-caldav + +Point it at a directory of markdown notes — a [zk](https://github.com/zk-org/zk) or Obsidian vault — +and every `- [ ]` checkbox becomes a task in your CalDAV client. Check a task off on your phone and +the markdown file is rewritten to `- [x]`. Edit in vim and the client picks it up. + +The notes stay the source of truth. Nothing is injected into them — no IDs, no metadata blocks, no +reformatting. Untouched lines are preserved byte for byte. + +``` +~/notes/daily/2026-01-07.md Tasks.org on your phone + ┌────────────────────────┐ +- [ ] [#A] Plan the seed order ───▶ │ ! Plan the seed order │ + - [ ] Draft the bed layout │ ☐ Draft the bed… │ + - Measure the north plot │ Measure the nor… │ +- [x] Refill the bird feeder │ ☑ Refill the bird fee… │ + └────────────────────────┘ +``` + +**Supported:** nested subtasks (org-mode style), `[#A]`/`[#B]`/`[#C]` priorities, descriptions from +non-checkbox sub-bullets, and completion round-tripping back to the file. + +See **[SPEC.md](./SPEC.md)** for the full design. + +## Quickstart + +```sh +uv venv && uv pip install -e ".[dev]" + +# See what the parser makes of your vault — read-only, no server, no writes. +mdcaldav --vault ~/notes scan + +# Serve it. Use a COPY of your vault until you trust the write-back. +mdcaldav --vault /path/to/vault-copy serve --host localhost:5232 --no-auth +``` + +Then point a client at `http://localhost:5232/`. Each top-level directory becomes a task list. + +`--no-auth` is localhost-only. For anything else, create an htpasswd file and drop `--no-auth`. + +### Commands + +| Command | Purpose | +| --- | --- | +| `mdcaldav scan` | print the task tree the server would expose | +| `mdcaldav doctor` | index health, ambiguous titles, orphaned subtasks | +| `mdcaldav serve` | run the CalDAV server | + +## Docker + +```sh +cp .env.example .env # set VAULT_PATH, VAULT_UID, VAULT_GID + +# One user, bcrypt-hashed: +docker run --rm markdown-to-caldav:latest python -c \ + "import bcrypt;print('you:'+bcrypt.hashpw(b'yourpassword',bcrypt.gensalt()).decode())" \ + > docker/users + +docker compose up -d +``` + +Serves on `127.0.0.1:5233`. Point a client at `http://127.0.0.1:5233/`. + +Three things the compose file gets right, and which matter: + +- **`user:` is set from `VAULT_UID`/`VAULT_GID`.** Write-back happens as that uid, so notes stay + owned by you rather than by root. Get this wrong and the container either can't write or leaves + root-owned files in your vault. +- **The index lives in `./data`, not in the vault.** It's a derived cache, not a note. +- **The port binds to `127.0.0.1`.** This service rewrites your notes; don't expose it to a LAN + without TLS and a real password. + +`backup_dir` is enabled by default in `docker/mdcaldav.toml`, snapshotting each file to +`./data/backups` before its first modification. Turn it off once you trust it. + +Read-only inspection without starting the server: + +```sh +docker compose run --rm markdown-to-caldav mdcaldav --config /config/mdcaldav.toml scan +``` + +## Deploying alongside an existing Radicale + +**You cannot add this to your existing Radicale instance.** `[storage] type` is a single global +option and Radicale holds exactly one storage backend per process — setting it to `mdcaldav.storage` +would make that backend serve *every* path, and your existing calendars and contacts (stored by +`multifilesystem`) would stop resolving. There is no per-collection storage routing. + +Run a **second instance** instead. Two ways to present it: + +**Separate ports** — simplest. Existing Radicale on 5232, this on 5233. Add both accounts in your +client. + +**One hostname, two paths** — on the reverse-proxy machine, route `/notes/` here and tell +this instance its prefix: + +```nginx +location /notes/ { + proxy_pass http://notes-host.lan:5233; # NO trailing slash — see below + proxy_set_header X-Script-Name /notes; + proxy_set_header Host $host; +} +location / { proxy_pass http://existing-radicale.lan:5232; } +``` + +> **The missing trailing slash is load-bearing.** Radicale strips the prefix +> *itself* and re-adds it when generating hrefs. With `proxy_pass .../;` nginx +> strips it first, Radicale then emits hrefs without `/notes`, and clients walk +> to collection URLs that don't exist. Same reason the Caddy example uses +> `handle` rather than `handle_path`. + +Verified end-to-end: through a sub-path proxy, collections come back as +`/notes/tyler/daily/` and completing a task writes through to the file. + +Full configs for both web servers: [`reverse-proxy/`](./reverse-proxy). + +## Production deployment + +```sh +cp .env.example .env # VAULT_PATH, VAULT_UID, VAULT_GID, BIND_ADDRESS +docker run --rm markdown-to-caldav:latest python -c \ + "import bcrypt;print('you:'+bcrypt.hashpw(b'yourpassword',bcrypt.gensalt()).decode())" \ + > docker/users + +docker compose -f docker-compose.prod.yml up -d +``` + +**There is no proxy container in this stack by design.** TLS is terminated by the central +reverse proxy on a separate machine, which reaches this host over the LAN on +`BIND_PORT` (default 5233). Reference configs for that machine — nginx and Caddy, plain +hostname and sub-path variants — are in [`reverse-proxy/`](./reverse-proxy). + +Because the published port is plain HTTP: + +- Set `BIND_ADDRESS` to this host's LAN address rather than leaving it on all interfaces. +- **Firewall the port to the proxy's IP.** Anything else on the LAN can otherwise reach it. +- Keep htpasswd auth on. The port is not private just because it isn't public. + +The app runs with a read-only root filesystem, all capabilities dropped, and +`no-new-privileges`; only `/vault`, `/data` and a 64 MB `/tmp` are writable. That +configuration is tested, not aspirational. + +To run Syncthing inside the same stack, add `--profile syncthing`. Omit it if Syncthing +already runs on the host and simply shares `VAULT_PATH`. + +## Serving a Syncthing folder + +This works, and it's a good setup — but a Syncthing folder is not just your notes, and two +of its features will corrupt your task list if ignored. + +**What gets excluded, and why it matters** + +| Path | What it is | If indexed | +|---|---|---| +| `.stversions/` | **old copies of your notes** | every task duplicated, once per retained version | +| `*.sync-conflict-*.md` | whole duplicate notes from conflicting edits | entire files duplicated in your client | +| `.syncthing.*`, `~syncthing~*` | partial in-flight writes | torn, half-parsed tasks | + +All are excluded by default. `.stversions/` is the one that bites hardest: with file +versioning enabled, it holds complete historical copies of every note, so indexing it +multiplies your task list by your retention depth. + +**Operational notes** + +- **Match the uid.** `VAULT_UID`/`VAULT_GID` must match whoever owns the Syncthing folder. + Mismatched ownership means either write-back fails or Syncthing fights over permissions. +- **Expect occasional conflicts.** If the server completes a task at the same moment a + remote edit arrives, Syncthing keeps one version and renames the other to + `*.sync-conflict-*`. Those are excluded from indexing, so tasks won't duplicate, but the + edit is parked in that file until you merge it. `mdcaldav doctor` lists any present. +- **Reduce the conflict window** by keeping `backup_dir` on and letting this host be the + only automated writer. Writes are single-line and atomic (`os.replace`), so Syncthing + always observes a complete file, never a partial one. +- **Verify before trusting it:** + + ```sh + docker compose -f docker-compose.prod.yml run --rm markdown-to-caldav \ + mdcaldav --config /config/mdcaldav.toml doctor + ``` + + Check the task count is what you expect. If it's a large multiple of reality, something + under `.stversions/` is being indexed. + +## How it works + +Radicale provides the CalDAV protocol layer; this project is a Radicale storage plugin that presents +markdown as collections of `VTODO` items. A SQLite sidecar index (outside the vault) keeps task UIDs +stable across edits so clients don't lose their state. + +The plugin is loaded by name, so it works under stock Radicale — the Docker image runs +`radicale --config`, not a custom server. Because Radicale's config schema rejects unknown keys, the +vault config is passed out of band via `MDCALDAV_CONFIG`. + +## Notes and caveats + +- **Back up first.** This software rewrites your notes. Point it at a copy until you trust it. +- **Nesting in Thunderbird:** subtasks use `RELATED-TO;RELTYPE=PARENT`. jtx Board and Tasks.org + render the hierarchy; Thunderbird ignores it and shows a flat list. The tasks are all still there + and still completable. +- **Carry-forward duplicates are distinct tasks.** Copying an unfinished task into tomorrow's daily + note gives you two independent tasks, not one in two places. Completing one does not tick the + other. See SPEC §6.3 — this is deliberate. +- **Creating a task** from a client appends it to `/inbox.md` under an `## Inbox` + heading, so it stays in the list you created it in. +- **License:** GPLv3, because Radicale is loaded in-process as a library. + +## Development + +```sh +.venv/bin/python -m pytest # 86 tests +``` + +Test fixtures in `tests/fixtures/vault/` are synthetic. Never commit real notes into this +repository — the suite must be safe to share and must not depend on private content. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..dd8aa30 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,715 @@ +# markdown-to-caldav — Implementation Specification + +**Status:** Implemented · **Date:** 2026-07-31 · **Target runtime:** Python 3.14, Radicale 3.7.7 + +--- + +## 1. Overview + +`markdown-to-caldav` exposes the TODO checkboxes scattered through a directory of markdown notes +(a [zk](https://github.com/zk-org/zk) or Obsidian vault) as a **CalDAV task server**. Standard +CalDAV clients — DAVx⁵ + Tasks.org, jtx Board, Thunderbird — discover the vault as one or more task +lists, display the tasks with their nesting and priorities, and can check them off. Completing a +task in the client rewrites the corresponding `- [ ]` to `- [x]` in the original markdown file. + +The markdown files remain the single source of truth. There is no separate task database that can +drift; the sidecar index (§6) is a derived cache that can be deleted and rebuilt at any time. + +### 1.1 Design goals + +1. **The vault is authoritative.** Every task the server serves is a line in a file. Nothing exists + only in the server. +2. **Notes stay human-first.** No IDs, no metadata blocks, no reformatting injected into the + markdown. A file edited by the server must remain something you'd be happy to open in vim. +3. **Bidirectional, promptly.** Vim edits appear in the client within seconds; client edits appear + in the file immediately. +4. **Never corrupt notes.** A bug, a crash, or a misbehaving client must not be able to mangle a + file. Concurrent edits are detected and rejected, not merged blindly. + +### 1.2 Architecture + +Radicale provides the protocol layer; we provide a storage plugin that presents markdown as +calendar collections of `VTODO` items. + +``` +CalDAV client (Tasks.org / jtx Board / Thunderbird) + │ HTTP: PROPFIND, REPORT, PUT, DELETE + ▼ +Radicale 3.7.7 ← WebDAV/CalDAV verbs, XML, auth, sync-collection, client quirks + │ BaseStorage / BaseCollection API + ▼ +mdcaldav.storage:Storage ← this project + ├── parser.py markdown → task tree (exact byte spans) + ├── ical.py task ⇄ VTODO text + ├── index.py SQLite sidecar: stable UIDs, sync generations + ├── writer.py surgical, atomic markdown edits + └── watcher.py filesystem watch → incremental rescan + ▼ +~/notes/**/*.md +``` + +**Why a Radicale plugin rather than a standalone server.** CalDAV client compatibility is the +dominant risk in a project like this — `sync-collection` REPORT, ETag semantics, `calendar-query` +filtering, and a long tail of per-client quirks. Radicale has absorbed that work over a decade. We +write the markdown↔VTODO mapping (the part that is actually novel) and inherit the rest. + +**License consequence:** Radicale is GPLv3. Running as an in-process plugin makes any distribution +of this project GPL-encumbered. Acceptable for a personal tool; stated here so it is not a surprise. + +--- + +## 2. Source format + +### 2.1 Task line grammar + +``` +[]()?(<inline-meta>)* +``` + +| Element | Accepted | +|---|---| +| `indent` | spaces or tabs; tab width configurable (default 4) | +| `bullet` | `-`, `*`, `+` | +| `state` | see §2.2 | +| `priority` | `[#A]`, `[#B]`, `[#C]` — org-mode placement, immediately after the checkbox | +| `title` | free text to end of line, minus trailing inline-meta | +| `inline-meta` | `#tag` → `CATEGORIES`; optional due syntax (§2.5) | + +Example exercising every element: + +```markdown +- [ ] [#A] Ship the release #work @due(2026-08-04) +``` + +### 2.2 States + +Default state map (configurable via `[tasks].states`): + +| Marker | `STATUS` | +|---|---| +| `[ ]` | `NEEDS-ACTION` | +| `[x]`, `[X]` | `COMPLETED` | +| `[/]`, `[>]` | `IN-PROCESS` | +| `[-]` | `CANCELLED` | + +Unrecognized markers (`[?]`, `[!]`) are treated as `NEEDS-ACTION` and the original character is +preserved on write-back, so third-party checkbox conventions survive a round trip. + +### 2.3 Structure: tasks, descriptions, and group nodes + +Nesting is determined by indentation. Each bullet is classified by two questions: does it have a +checkbox, and does it have any checkbox descendants? + +| Has checkbox | Has checkbox descendants | Classification | +|---|---|---| +| yes | — | **Task** | +| no | no | **Description** — appended to the nearest ancestor task's `DESCRIPTION`; dropped if there is no ancestor task | +| no | yes | **Group node** — not a task; its text is appended to `group_path`, and its checkbox children re-parent to the nearest ancestor **task** (or become roots) | + +This three-way rule is what makes real vaults work. Each case below occurs routinely in daily-note +style vaults and is covered by a fixture in `tests/fixtures/vault/`. + +**Description:** +```markdown +- [x] Order the cover crop + - Arrived on the 12th, invoice is in the binder +``` +→ one task, `SUMMARY:Order the cover crop`, +`DESCRIPTION:Arrived on the 12th, invoice is in the binder`. + +**Group node:** +```markdown +- set up the cold frame + - [x] Cut the polycarbonate + - [ ] Hinge the lid +``` +→ two root tasks (`Cut the polycarbonate`, `Hinge the lid`), each with `CATEGORIES` including +`set up the cold frame`. The plain parent is *not* a task — it has no checkbox and cannot be +completed — but its text is preserved as context. + +**Neither:** +```markdown +### Retrospective + +- Start the tomatoes earlier next year +- Buy fewer squash varieties +``` +→ no tasks, and no description attached to anything, because no ancestor task exists. + +### 2.4 Context: headings and frontmatter + +- ATX headings (`#`…`######`) maintain a `heading_path` stack. Checkboxes are collected under **any** + heading, not only `## TODO` — daily notes routinely carry tasks under topic headings such as + `## Greenhouse repairs` alongside a `## Notes` section that has none. +- YAML frontmatter delimited by `---` at the top of the file is parsed for `tags` (→ `CATEGORIES`) + and `date`. It is never treated as content. + +### 2.5 Optional due dates + +Off by default. When `[tasks].due_syntax` is configured, the listed patterns are stripped from the +title and mapped to `DUE`. Suggested patterns: `📅 %Y-%m-%d` (Obsidian Tasks), `@due(%Y-%m-%d)`. +A daily note's frontmatter `date` may optionally seed `DUE` via `[tasks].daily_note_due`. + +### 2.6 Exclusions + +Content that must **never** yield a task: + +- fenced code blocks (``` and `~~~`), including a `- [ ]` inside a shell snippet +- indented code blocks (4+ spaces beyond list context) +- YAML frontmatter +- paths matching `[vault].exclude` + +Path patterns use real glob semantics (`globs.py`), **not** `fnmatch`: `**/` means zero or +more segments and `*` does not cross `/`. With `fnmatch`, `**/*.md` fails to match a +top-level `README.md` while `*.tmp` wrongly matches `a/b.tmp` — both wrong on a real vault. + +Filtering is applied to **targeted rescans as well as full scans**. The watcher rescans +individual paths, so an unfiltered targeted rescan would index excluded files the moment +something wrote to them. + +Default exclusions cover `.git/**`, `.zk/**`, `.obsidian/**`, `assets/**`, and Syncthing's +artefacts (§9.5). + +### 2.6.1 Syncthing-backed vaults + +A Syncthing folder contains three classes of file that must never be parsed: + +| Path | Contents | Consequence if indexed | +|---|---|---| +| `.stversions/**` | **complete historical copies of notes** | every task duplicated once per retained version | +| `**/*.sync-conflict-*` | whole duplicate notes from conflicting edits | entire files duplicated | +| `**/.syncthing.*`, `**/~syncthing~*` | partial in-flight writes | torn tasks | + +`.stversions/` is the severe one: with versioning enabled it multiplies the entire task +list by the retention depth. + +Conflict files are excluded rather than merged — a conflict is a human decision. `doctor` +reports any present so they are not silently forgotten. + +Write-back is compatible with Syncthing because edits are atomic (`os.replace`), so +Syncthing always observes a complete file. The residual risk is a genuine concurrent edit +on two devices, which Syncthing resolves by producing a conflict file; the task is not +lost, but the losing edit waits in that file. + +### 2.7 Parser implementation note + +The parser is a **hand-rolled line scanner**, not markdown-it-py or a CommonMark AST. + +Rationale: write-back requires exact byte spans for the checkbox marker, the priority token, and the +title, so an edit can touch only those bytes (§7). AST libraries either discard source spans or give +line-level maps that are too coarse to edit surgically. We also only need a small subset of markdown +(lists, headings, fences, frontmatter). The tradeoff is that exotic CommonMark constructs — lists +inside block quotes, lazy continuation lines — are out of scope; §2.6 exclusions and a fixture suite +of real files cover what actually occurs. + +--- + +## 3. Data model + +```python +@dataclass +class Source: + rel_path: str # vault-relative, POSIX separators + line_no: int # 0-based + line_span: tuple[int, int] # byte offsets of the full line + marker_span: tuple[int, int] # byte offsets of the state char inside [ ] + title_span: tuple[int, int] # byte offsets of the title text + prio_span: tuple[int, int] | None + indent: str # verbatim leading whitespace + bullet: str # '-', '*', '+' + +@dataclass +class Task: + uid: str + title: str + status: Status # NEEDS-ACTION | COMPLETED | IN-PROCESS | CANCELLED + raw_marker: str # verbatim char, for lossless round trip + priority: int | None # 1..9, RFC 5545 + description: str | None + due: date | None + categories: list[str] + parent_uid: str | None + children: list[str] + heading_path: tuple[str, ...] + group_path: tuple[str, ...] + sibling_index: int + depth: int + source: Source + content_hash: str # blake2b of the normalized line +``` + +--- + +## 4. iCalendar mapping + +Each task becomes one `VTODO` in its own `.ics` resource, href `<uid>.ics`. + +| Markdown | VTODO property | +|---|---| +| title | `SUMMARY` | +| `[ ]` | `STATUS:NEEDS-ACTION` | +| `[x]` | `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` + `COMPLETED` | +| `[/]` | `STATUS:IN-PROCESS` | +| `[-]` | `STATUS:CANCELLED` | +| `[#A]` / `[#B]` / `[#C]` | `PRIORITY:1` / `5` / `9` | +| description sub-bullets | `DESCRIPTION` (newline-joined) | +| indentation | `RELATED-TO;RELTYPE=PARENT:<parent uid>` | +| `heading_path` + `group_path` + tags | `CATEGORIES` | +| due syntax (§2.5) | `DUE;VALUE=DATE` | +| sidecar UID | `UID` | +| — | `X-MD-SOURCE-FILE`, `X-MD-SOURCE-LINE` | + +Priority mapping follows RFC 5545 §3.8.1.9, which defines 1–4 as high, 5 as medium, 6–9 as low; +`A→1, B→5, C→9` places each org priority in the centre of its band. Inbound values are mapped back +by band, so a client sending `PRIORITY:3` yields `[#A]`. + +### 4.1 Timestamps + +`COMPLETED` has no markdown representation. Resolution order: + +1. the timestamp recorded in the sidecar when the server observed the `[ ]`→`[x]` transition; +2. otherwise the file's mtime (for checkboxes already complete at first index); +3. if neither is available, omit the property. + +`LAST-MODIFIED` and `DTSTAMP` derive from the source file's mtime. + +### 4.2 Nesting caveat + +`RELATED-TO;RELTYPE=PARENT` is the RFC 5545 mechanism for subtasks. jtx Board and Tasks.org render +the hierarchy. **Thunderbird ignores `RELATED-TO`** and will show a flat list of every task — the +tasks are all present and completable, just not indented. Document this in the README rather than +working around it. + +### 4.3 Generation + +We emit iCalendar **text** and hand it to `radicale.item.Item(text=...)`, which accepts a raw string +and parses lazily with vobject. This avoids a second iCalendar library as a dependency — vobject +arrives with Radicale. + +--- + +## 5. Collections + +`[collections].group_by` selects a strategy: + +| Value | Collections produced | +|---|---| +| `directory` *(default)* | one per directory at `depth` (default 1): `daily`, `work`, `zets` | +| `file` | one per markdown file | +| `heading` | one per top-level heading across the vault | +| `tag` | one per frontmatter tag | +| `single` | one collection for the whole vault | + +Strategy interface: + +```python +class GroupingStrategy(Protocol): + def collection_for(self, task: Task) -> str: ... # returns collection href + def display_name(self, href: str) -> str: ... +``` + +Collection hrefs are slugified and stable. A task whose file moves between collections is treated as +a delete from the old collection and a create in the new one — CalDAV has no cross-collection move +that clients handle reliably. + +Each collection reports `tag = "VCALENDAR"` and advertises `VTODO` in +`supported-calendar-component-set`, so clients present it as a task list rather than a calendar. + +--- + +## 6. Identity and the sidecar index + +The central problem: a `VTODO` needs a `UID` that is stable across arbitrary vim edits, but markdown +carries no identifiers and we have committed to not writing any into the notes. + +### 6.1 Schema + +SQLite at `[index].db` (default `~/.cache/markdown-to-caldav/index.db`): + +```sql +CREATE TABLE meta (schema_version INT, generation INT); + +CREATE TABLE files ( + rel_path TEXT PRIMARY KEY, mtime REAL, size INT, hash TEXT, last_scan_gen INT); + +CREATE TABLE tasks ( + uid TEXT PRIMARY KEY, + collection TEXT, rel_path TEXT, + heading_path TEXT, group_path TEXT, + sibling_index INT, depth INT, + title_norm TEXT, content_hash TEXT, + status TEXT, completed_at TEXT, + parent_uid TEXT, + first_seen_gen INT, last_modified_gen INT, deleted_gen INT); + +CREATE INDEX tasks_lookup ON tasks(rel_path, heading_path, title_norm); +CREATE INDEX tasks_sync ON tasks(last_modified_gen); +``` + +`title_norm` is the title lowercased, whitespace-collapsed, and stripped of inline meta. + +### 6.2 Matching algorithm + +On rescan of a file, each parsed task is matched against surviving rows **for that file first**: + +1. **Exact** — `(rel_path, heading_path, group_path, title_norm)` matches → reuse UID. + Survives reordering and status changes, the overwhelmingly common case. +2. **Positional** — `(rel_path, heading_path, depth, sibling_index)` matches and title similarity + ≥ `[index].similarity_threshold` (default 0.75, via `rapidfuzz`) → reuse UID. + Survives an in-place retitle. +3. **Cross-file move** — considered **only** when the previous occurrence disappeared in this same + scan *and* the match is unique vault-wide → reuse UID. +4. No match → mint a new UID (`uuid4`). +5. A row not seen this scan → tombstone: set `deleted_gen`, retain for the sync window (§6.3). + +Ambiguity within one file+heading (two identical titles) is resolved by `sibling_index`. + +### 6.3 Why rule 3 is guarded + +Carry-forward is a normal habit in daily notes: an unfinished task gets copied into the next day's +file while the previous day's copy stays put as a record. The fixtures model this with +`Water the seedlings`, which appears in three consecutive daily notes. These are deliberate copies, +not moves. Matching on title alone across files would collapse them into one task and make +completing Wednesday's item silently tick Monday's. + +The disappearance requirement is standard rename detection: a task is only considered *moved* if it +is no longer where it was. If both copies exist, both keep their own identity. + +### 6.4 Rebuild + +The index is a cache. Deleting the database and rescanning is always safe; the cost is that every +UID is regenerated, so clients see a full replacement of their task list. `mdcaldav doctor` reports +index/vault divergence without modifying anything. + +--- + +## 7. Write-back + +### 7.1 Supported operations + +| Operation | Edit performed | +|---|---| +| status change | rewrite **only** the state character inside `[ ]` (`marker_span`) | +| retitle | replace `title_span`; indent, bullet, checkbox, priority and inline meta preserved | +| priority set/clear | insert, replace, or remove the `[#X]` token at `prio_span` | +| description change | replace the contiguous non-checkbox child-bullet block at the child indent | +| create | append under `[write].inbox_heading` in the **collection's** inbox, creating file/heading if absent | +| delete | remove the task line and its description lines; subtree per `[write].delete_children` | + +`[write].delete_children` ∈ `cascade` (default — remove the whole subtree, matching client +expectations), `orphan` (promote children one level), `reject` (return 409 if the task has children). + +### 7.2 Safety invariants + +These are the load-bearing requirements of the whole project. + +1. **Atomic replacement.** Write to a temp file in the same directory, `fsync`, then `os.replace`. + File mode and ownership are preserved. A crash mid-write leaves the original intact. +2. **Byte-identical elsewhere.** Every line the operation does not target must be unchanged, byte + for byte. No reflow, no trailing-whitespace stripping, no indent normalization, no line-ending + conversion. Final-newline presence is preserved exactly. +3. **Conflict detection before write**, in two layers: + - *Client staleness* is Radicale's job: it checks `If-Match` against the item's ETag and returns + **412 Precondition Failed** before `upload()` is ever called. Since our ETag derives from the + task's content hash, any external edit to that task invalidates the client's cached copy. + - *Stale spans* are ours: byte offsets captured at parse time are meaningless once the file + changes. Before every write the writer compares the file's hash to what `files` recorded and + reparses if it differs, so an edit is only ever applied through freshly computed offsets. If + the task's UID no longer resolves after that reparse, the write is refused rather than guessed. + + Note that an in-place retitle is *not* a conflict — identity rule 2 (§6.2) says it is the same + task, so the write lands on the renamed line. A vim edit is never silently overwritten, but it is + also not treated as a different task just because its text moved. +4. **Locking.** Radicale's `acquire_lock("w")` serializes writers process-wide; an additional + per-file lock guards the read-modify-write cycle. +5. **Optional backups.** When `[write].backup_dir` is set, the original is copied there once before + the first modification of each file per run. + +Under directory grouping the inbox resolves **per collection**: a task created in `daily` is +appended to `daily/inbox.md`, not to a vault-root `inbox.md`. Otherwise the new task would be +assigned to a different collection and disappear from the list the client just created it in. + +### 7.3 Inbound PUT handling + +1. Parse the submitted `.ics`. +2. Resolve `UID` → sidecar row → file and line. Unknown UID plus `[write].allow_create` → create in + the collection's inbox; unknown UID without it → 403. +3. On create, **adopt the client's UID** rather than minting one, so the item remains at the href the + client PUT to. The sidecar row is renamed to that UID after the file is reindexed. +4. Diff submitted properties against the task's current state; compute the minimal set of edits. +5. Apply under lock (§7.2); bump the generation; return the new ETag. + +Priority is compared by *band*, not by exact value: a client sending `PRIORITY:3` against a task +already marked `[#A]` is not a change, so the markdown is left alone. + +Properties we do not model (`RRULE`, `VALARM`, `GEO`, …) are ignored on input rather than rejected, +so a client that always round-trips its full object does not fail. This is stated in §11 as a +non-goal, and `mdcaldav doctor` warns when ignored properties are seen. + +--- + +## 8. Change detection + +**Freshness comes from the read path.** Every `discover`, `get_all`, `get_multi` and `sync` first +calls `refresh_if_stale()`, which stats the vault and reparses only files whose mtime/size no longer +match the index. Because CalDAV has no server push — clients poll — this alone guarantees that an +edit made in vim is visible on the client's next sync. + +**Watching is therefore an optimization, not a correctness mechanism.** A `watchdog` observer with +~300 ms debounce moves rescan cost off the request path, which matters for large vaults. It is +enabled by `mdcaldav serve` (disable with `--no-watch`) and off by default elsewhere, so embedding +the storage plugin never silently spawns threads. `[vault].poll_interval` selects polling instead of +inotify, for network mounts. + +Each rescan batch increments `meta.generation`. + +### 8.1 Sync tokens + +Radicale's `BaseCollection.sync(old_token)` returns `(new_token, changed_hrefs)`. + +- Token format: `http://mdcaldav.local/ns/sync/<generation>`. +- Changed set: tasks with `last_modified_gen > old_gen`, plus tombstones with + `deleted_gen > old_gen`. +- Tombstones are pruned after `[index].tombstone_retention` generations (default 1000). A token + older than the retained window causes `sync()` to raise `ValueError`, which is Radicale's + documented signal for "force a full resync" — the correct behavior, not an error to suppress. + +--- + +## 9. Radicale integration + +### 9.1 Plugin contract + +Radicale loads storage via `utils.load_plugin(..., "storage", "Storage", BaseStorage, config)`, +which imports the configured module and fetches the attribute **`Storage`**. So `mdcaldav/storage.py` +must define `class Storage(BaseStorage)`. (Radicale 2.x required a class named `Collection`; that +guidance is stale and does not apply to 3.x.) + +Radicale validates its own configuration schema and **rejects unknown keys**, so the vault config +cannot ride along inside `[storage]`. It is supplied out of band: `mdcaldav.storage.set_config(cfg)` +before the plugin is instantiated (what the CLI does), or `MDCALDAV_CONFIG=/path/config.toml` in the +environment. + +```ini +[storage] +type = mdcaldav.storage + +[auth] +type = htpasswd +htpasswd_filename = ~/.config/markdown-to-caldav/users +htpasswd_encryption = bcrypt + +[server] +hosts = localhost:5232 +``` + +### 9.2 Methods to implement + +Verified against Radicale 3.7.7: + +```python +class Storage(BaseStorage): + def discover(self, path, depth="0", child_context_manager=None, + user_groups=set()) -> Iterable[CollectionOrItem] + def move(self, item, to_collection, to_href) -> None + def create_collection(self, href, items=None, props=None + ) -> tuple[BaseCollection, dict, list] + @contextmanager + def acquire_lock(self, mode, user="", *args, **kwargs) -> Iterator[None] + def verify(self) -> bool + +class Collection(BaseCollection): + path, owner, tag, etag, last_modified, is_principal # properties + def get_multi(self, hrefs) -> Iterable[tuple[str, Item | None]] + def get_all(self) -> Iterable[Item] + def get_filtered(self, filters) -> Iterable[tuple[Item, bool]] + def has_uid(self, uid) -> bool + def upload(self, href, item) -> tuple[Item, Item | None] + def delete(self, href=None) -> None + def get_meta(self, key=None); def set_meta(self, props) + def sync(self, old_token="") -> tuple[str, Iterable[str]] + def serialize(self, vcf_to_ics=False, ShareActions={}) -> str +``` + +`get_filtered` may return `(item, False)` to let Radicale apply the filter itself; we do this +initially and optimize `calendar-query` server-side only if profiling warrants it. + +`set_meta` on a markdown-backed collection has nowhere durable to write client-set properties +(display name, colour). These are held in memory keyed by collection href, so a client that renames +a list does not fail — but the rename does not touch the vault and does not survive a restart. + +**The principal follows the authenticated user.** Collections are served under whatever principal +the client authenticated as (`/tyler/daily/`, not a hardcoded `/vault/daily/`), so hrefs stay inside +the principal's namespace as clients expect. The vault itself is single-user; the principal name is +presentational. + +--- + +### 9.3 One backend per process + +`[storage] type` is a single global option and `Application` holds exactly one `BaseStorage` +instance; Radicale has no per-collection storage routing. Consequences: + +- This plugin **cannot be added to an existing Radicale instance**. Setting `type` would make it + serve every path, and collections held by `multifilesystem` would stop resolving. +- Deployment is therefore always a **second instance**, presented either on its own port or under a + reverse-proxy sub-path via `[server] script_name`. + +### 9.3.1 Reverse proxying at a sub-path + +Radicale strips `script_name` from the incoming URI itself (from `[server] script_name` or +the `X-Script-Name` / `SCRIPT_NAME` variables) and re-adds it when generating hrefs. + +The proxy must therefore pass the **full** path through, prefix included. Stripping it at +the proxy — `proxy_pass http://host:port/;` with a trailing slash in nginx, or Caddy's +`handle_path` — makes Radicale emit hrefs without the prefix, and clients then walk to +collection URLs that do not exist. Use nginx `proxy_pass` without a trailing slash, or +Caddy's `handle`, and set `X-Script-Name`. + +### 9.4 Root collection path + +`discover("/")` must yield a collection whose `path` is `""`. Radicale filters out any item whose +path does not match the request, so a root item reporting a non-empty path is silently dropped from +the multistatus — which breaks `current-user-principal` and leaves clients unable to discover +anything. This fails only when authentication is enabled, so it must be covered by an explicit test +(§12, test 12) rather than left to client-library behaviour. + +## 10. Configuration + +`~/.config/markdown-to-caldav/config.toml`: + +```toml +[vault] +path = "~/notes" +include = ["**/*.md"] +exclude = [".git/**", ".zk/**", ".obsidian/**", "assets/**"] +tab_width = 4 +watch = false # `serve` enables this; freshness never depends on it +# poll_interval = 2.0 # set to use polling instead of inotify + +[collections] +group_by = "directory" # directory | file | heading | tag | single +depth = 1 + +[tasks] +states = { " " = "NEEDS-ACTION", "x" = "COMPLETED", "/" = "IN-PROCESS", "-" = "CANCELLED" } +priority_map = { A = 1, B = 5, C = 9 } +due_syntax = [] # e.g. ["@due(%Y-%m-%d)", "📅 %Y-%m-%d"] + +[write] +allow_create = true +allow_delete = true +inbox = "inbox.md" +inbox_heading = "## Inbox" +delete_children = "cascade" # cascade | orphan | reject +# backup_dir = "~/.local/share/markdown-to-caldav/backups" + +[index] +db = "~/.cache/markdown-to-caldav/index.db" +similarity_threshold = 0.75 +tombstone_retention = 1000 +``` + +### 10.1 CLI + +| Command | Purpose | +|---|---| +| `mdcaldav serve` | run Radicale with this storage plugin | +| `mdcaldav scan` | index the vault and print the task tree; no server, no writes | +| `mdcaldav doctor` | report parse warnings, index divergence, ambiguous identities, ignored properties | + +`scan` is the primary debugging tool: it answers "what does the server think my notes say?" without +involving a client. + +--- + +## 11. Non-goals + +Explicitly out of scope for v1: + +- recurrence (`RRULE`) and alarms (`VALARM`) +- `VEVENT` / calendar sync; this is a task server only +- multi-user vaults, per-user views, sharing +- org-mode `.org` files (the *format* of org priorities is borrowed; the file format is not) +- timezone-aware scheduling beyond whole-date `DUE` +- conflict *merging* — conflicts are detected and rejected (§7.2), never auto-resolved + +--- + +## 12. Testing strategy + +| # | Test | Asserts | +|---|---|---| +| 1 | Fixture parse | Real vault files in `tests/fixtures/vault/` produce exact expected task trees, covering description bullets, group nodes, note-only bullets, non-`## TODO` headings, 5-level nesting | +| 2 | Round-trip invariant | parse → serialize with no mutation → file is byte-identical | +| 3 | Surgical write | Toggling one task produces a **one-line** diff and nothing else | +| 4 | Identity: reorder | Moving a task within a file preserves its UID | +| 5 | Identity: retitle | Editing a title in place preserves its UID | +| 6 | Identity: carry-forward | The same title in three daily notes keeps **three distinct** UIDs; completing one does not affect the others | +| 7 | Identity: true move | A task that disappears from A and appears in B keeps its UID | +| 8 | Conflict | Edit the file behind the server, then PUT → 412, file unchanged | +| 9 | Code fence | `- [ ]` inside a fenced block yields no task | +| 10 | CalDAV integration | Radicale in-process + `caldav` client: discover collections, REPORT, PUT a completion, assert the markdown changed and the ETag advanced | +| 11 | Sync token | Stale token raises `ValueError`; fresh token returns only changed hrefs | +| 12 | Root PROPFIND | `/` reports `current-user-principal`, guarding the §9.4 discovery failure | + +Fixtures in `tests/fixtures/vault/` are **synthetic** — invented content that reproduces the +structural cases (group nodes, description bullets, carry-forward duplicates, five-level nesting, +code blocks). Never commit real notes into this repository; the test suite must be safe to share and +must not depend on anyone's private content. + +### 12.1 Manual client checklist + +Point a client at a **throwaway copy** of a vault, never at live notes, until write-back is trusted. + +- [ ] DAVx⁵ discovers the collections; Tasks.org lists tasks with correct nesting and priority +- [ ] Completing on the phone rewrites `[ ]`→`[x]` in the file, and only that +- [ ] Editing in vim surfaces in the client after a sync +- [ ] jtx Board renders subtasks +- [ ] Thunderbird lists tasks flat (expected, §4.2) and completion still round-trips + +--- + +## 13. Repository layout + +``` +markdown-to-caldav/ +├── SPEC.md +├── README.md +├── pyproject.toml +├── src/mdcaldav/ +│ ├── config.py # TOML load + validation +│ ├── model.py # Task, Source, Status +│ ├── parser.py # markdown → task tree, byte spans +│ ├── ical.py # Task ⇄ VTODO text +│ ├── collections.py # grouping strategies +│ ├── index.py # SQLite sidecar, identity matching, generations +│ ├── writer.py # surgical atomic edits +│ ├── watcher.py # watchdog + debounce +│ ├── storage.py # Radicale Storage / Collection +│ └── cli.py # serve | scan | doctor +└── tests/ + ├── fixtures/vault/ # real notes + └── test_*.py +``` + +**Dependencies:** `radicale>=3.7`, `watchdog`, `rapidfuzz`. Dev: `pytest`, `caldav`. +`tomllib` and `sqlite3` are stdlib. `vobject` arrives with Radicale; no separate iCalendar library. + +--- + +## 14. Build order + +Each step is independently testable; the CalDAV layer came last deliberately, so the risky parsing +and identity work was proven before any protocol code existed. + +1. ✅ `model.py`, `config.py` +2. ✅ `parser.py` + fixtures + tests 1, 2, 9 — the foundation +3. ✅ `ical.py` + mapping tests +4. ✅ `index.py` + identity tests 4–7 — the subtlest logic in the project +5. ✅ `writer.py` + tests 3, 8 +6. ✅ `collections.py`, `watcher.py` +7. ✅ `storage.py` + tests 10, 11 +8. ✅ `cli.py`, README + +Remaining: systemd unit, and the manual client checklist in §12.1 against real clients. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..8f700f6 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,72 @@ +# Production stack. +# +# docker compose -f docker-compose.prod.yml up -d +# +# No reverse proxy here by design: TLS is terminated by the central reverse +# proxy on a separate machine, which connects to the published port below over +# the LAN. Example proxy configs for that machine live in docker/reverse-proxy/. +# +# Add `--profile syncthing` to also run Syncthing in this stack; omit it if +# Syncthing already runs on the host and merely shares VAULT_PATH. + +services: + markdown-to-caldav: + build: . + image: markdown-to-caldav:latest + container_name: markdown-to-caldav + restart: unless-stopped + + # Must match the uid/gid that owns the vault (the Syncthing folder), or + # write-back fails and Syncthing sees permission churn. + user: "${VAULT_UID:?set VAULT_UID}:${VAULT_GID:?set VAULT_GID}" + + ports: + # Reachable by the reverse-proxy machine over the LAN. BIND_ADDRESS + # defaults to all interfaces; set it to this host's LAN address to avoid + # listening anywhere else. Restrict to the proxy's IP at the firewall — + # this port is plain HTTP and speaks for your notes. + - "${BIND_ADDRESS:-0.0.0.0}:${BIND_PORT:-5233}:5232" + + volumes: + - ${VAULT_PATH:?set VAULT_PATH}:/vault + - ./docker:/config:ro + - ./data:/data + + environment: + MDCALDAV_CONFIG: /config/mdcaldav.toml + + # Hardening. The app writes only to /vault and /data; everything else can + # be immutable. + read_only: true + tmpfs: + - /tmp:size=64m + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + + mem_limit: 512m + cpus: 1.0 + logging: &logging + driver: json-file + options: { max-size: "10m", max-file: "3" } + + # Optional: run Syncthing here too, sharing the vault and uid. + syncthing: + image: syncthing/syncthing:latest + container_name: mdcaldav-syncthing + profiles: [syncthing] + restart: unless-stopped + user: "${VAULT_UID:?set VAULT_UID}:${VAULT_GID:?set VAULT_GID}" + hostname: mdcaldav-syncthing + volumes: + - ${VAULT_PATH:?set VAULT_PATH}:/var/syncthing/vault + - syncthing-config:/var/syncthing/config + ports: + - "${BIND_ADDRESS:-0.0.0.0}:8384:8384" # web UI — firewall this too + - "22000:22000/tcp" # sync protocol + - "22000:22000/udp" + - "21027:21027/udp" # discovery + logging: *logging + +volumes: + syncthing-config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3f8f1bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + markdown-to-caldav: + build: . + image: markdown-to-caldav:latest + container_name: markdown-to-caldav + restart: unless-stopped + + # Files written back into your notes must stay owned by you, not root. + # Defaults to 1000:1000; override in .env or the environment. + user: "${VAULT_UID:-1000}:${VAULT_GID:-1000}" + + ports: + # Host 5233 avoids colliding with an existing Radicale on 5232. + - "127.0.0.1:5233:5232" + + volumes: + - ${VAULT_PATH:-./vault}:/vault + - ./docker:/config:ro + - ./data:/data + + environment: + MDCALDAV_CONFIG: /config/mdcaldav.toml + + security_opt: + - no-new-privileges:true diff --git a/docker/mdcaldav.toml b/docker/mdcaldav.toml new file mode 100644 index 0000000..622d5bb --- /dev/null +++ b/docker/mdcaldav.toml @@ -0,0 +1,25 @@ +[vault] +path = "/vault" +include = ["**/*.md"] +exclude = [".git/**", ".zk/**", ".obsidian/**", "assets/**"] +# Long-running server: keep the index warm off the request path. +watch = true +# Bind mounts sometimes drop inotify events; uncomment to poll instead. +# poll_interval = 2.0 + +[collections] +group_by = "directory" +depth = 1 + +[write] +allow_create = true +allow_delete = true +inbox = "inbox.md" +inbox_heading = "## Inbox" +delete_children = "cascade" +# Recommended until you trust write-back. Kept outside the vault. +backup_dir = "/data/backups" + +[index] +# Must NOT live inside /vault — it is a derived cache, not a note. +db = "/data/index.db" diff --git a/docker/radicale.conf b/docker/radicale.conf new file mode 100644 index 0000000..36eaea8 --- /dev/null +++ b/docker/radicale.conf @@ -0,0 +1,21 @@ +[server] +hosts = 0.0.0.0:5232 + +[storage] +# Our plugin. The vault config itself comes from MDCALDAV_CONFIG, because +# Radicale rejects unknown keys in its own schema. +type = mdcaldav.storage + +[auth] +type = htpasswd +htpasswd_filename = /config/users +htpasswd_encryption = autodetect + +[logging] +level = info + +# Uncomment when serving behind a reverse proxy at a sub-path, so this instance +# can share a hostname with an existing Radicale: +# location /notes/ { proxy_pass http://127.0.0.1:5233/; } +# [server] +# script_name = /notes diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0994d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "markdown-to-caldav" +version = "0.1.0" +description = "Expose TODO checkboxes in a markdown vault (zk / Obsidian) as a CalDAV task server" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "GPL-3.0-or-later" } + +dependencies = [ + "radicale>=3.7", + "watchdog>=4.0", + "rapidfuzz>=3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "caldav>=1.3", +] + +[project.scripts] +mdcaldav = "mdcaldav.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mdcaldav"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/reverse-proxy/Caddyfile.example b/reverse-proxy/Caddyfile.example new file mode 100644 index 0000000..8b00323 --- /dev/null +++ b/reverse-proxy/Caddyfile.example @@ -0,0 +1,30 @@ +# Reference config for the CENTRAL REVERSE PROXY MACHINE — not for this host. +# This stack publishes plain HTTP on <notes-host>:5233; TLS terminates here. +# +# Replace notes-host.lan with the LAN address of the machine running the +# markdown-to-caldav stack. + +# --- Option A: its own hostname --------------------------------------------- + +notes.example.com { + encode zstd gzip + reverse_proxy notes-host.lan:5233 +} + +# --- Option B: share a hostname with an existing Radicale ------------------- +# +# `handle` (not `handle_path`) keeps the /notes prefix in the request: Radicale +# strips script_name itself and re-adds it when generating hrefs. Stripping at +# the proxy instead yields hrefs missing the prefix, and clients then walk to +# collection URLs that do not exist. +# +# dav.example.com { +# handle /notes/* { +# reverse_proxy notes-host.lan:5233 { +# header_up X-Script-Name /notes +# } +# } +# handle { +# reverse_proxy existing-radicale.lan:5232 +# } +# } diff --git a/reverse-proxy/nginx.conf.example b/reverse-proxy/nginx.conf.example new file mode 100644 index 0000000..73b9406 --- /dev/null +++ b/reverse-proxy/nginx.conf.example @@ -0,0 +1,54 @@ +# Reference config for the CENTRAL REVERSE PROXY MACHINE — not for this host. +# This stack publishes plain HTTP on <notes-host>:5233; TLS terminates here. +# +# Replace notes-host.lan with the LAN address of the machine running the +# markdown-to-caldav stack. + +upstream mdcaldav { + server notes-host.lan:5233; +} + +# --- Option A: its own hostname --------------------------------------------- + +server { + listen 443 ssl; + http2 on; + server_name notes.example.com; + + ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem; + + # CalDAV bodies are small, but clients send large PROPFIND/REPORT XML. + client_max_body_size 100M; + + location / { + proxy_pass http://mdcaldav; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# --- Option B: share a hostname with an existing Radicale ------------------- +# +# NOTE the absence of a trailing slash on proxy_pass. With a trailing slash +# nginx replaces the /notes/ prefix with /, Radicale then generates hrefs +# without the prefix, and clients walk to URLs that do not exist. Radicale +# strips script_name itself, so send it the full path. +# +# server { +# listen 443 ssl; +# server_name dav.example.com; +# +# location /notes/ { +# proxy_pass http://mdcaldav; # no trailing slash +# proxy_set_header X-Script-Name /notes; +# proxy_set_header Host $host; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# } +# +# location / { +# proxy_pass http://existing-radicale.lan:5232; +# } +# } diff --git a/src/mdcaldav/__init__.py b/src/mdcaldav/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mdcaldav/cli.py b/src/mdcaldav/cli.py new file mode 100644 index 0000000..139c234 --- /dev/null +++ b/src/mdcaldav/cli.py @@ -0,0 +1,173 @@ +"""Command line: serve | scan | doctor (SPEC 10.1).""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from .config import CONFLICT_GLOBS, Config +from .globs import matches_any +from .index import Index +from .model import Status + +MARKS = { + Status.NEEDS_ACTION: " ", + Status.COMPLETED: "x", + Status.IN_PROCESS: "/", + Status.CANCELLED: "-", +} + + +def _load_config(args) -> Config: + cfg = Config.load(args.config) if args.config else Config() + if args.vault: + cfg.vault.path = Path(args.vault).expanduser() + return cfg + + +def cmd_scan(args) -> int: + cfg = _load_config(args) + index = Index(cfg) + index.rescan() + + by_collection: dict[str, list] = {} + for task in index.tasks.values(): + by_collection.setdefault(task.collection, []).append(task) + + total = 0 + for collection in sorted(by_collection): + tasks = by_collection[collection] + print(f"\n{collection} ({len(tasks)} tasks)") + roots = [t for t in tasks if t.parent_uid is None] + for task in sorted(roots, key=lambda t: (t.source.rel_path, t.source.line_no)): + total += _print_tree(index, task, 0) + print(f"\n{total} tasks in {len(by_collection)} collections") + index.close() + return 0 + + +def _print_tree(index: Index, task, depth: int) -> int: + prio = "" + if letter := index.cfg.tasks.priority_letter(task.priority): + prio = f"[#{letter}] " + print(f" {' ' * depth}- [{MARKS[task.status]}] {prio}{task.title}") + if task.description: + for line in task.description.split("\n"): + print(f" {' ' * depth} {line}") + count = 1 + for child_uid in task.children: + if child := index.tasks.get(child_uid): + count += _print_tree(index, child, depth + 1) + return count + + +def cmd_doctor(args) -> int: + cfg = _load_config(args) + index = Index(cfg) + index.rescan() + + files = index.discover_files() + print(f"vault: {index.vault}") + print(f"files: {len(files)}") + print(f"tasks: {len(index.tasks)}") + print(f"collections: {', '.join(index.collections()) or '(none)'}") + print(f"generation: {index.generation}") + + tombstones = index.db.execute( + "SELECT COUNT(*) FROM tasks WHERE deleted_gen IS NOT NULL" + ).fetchone()[0] + print(f"tombstones: {tombstones}") + + orphans = [ + t.uid + for t in index.tasks.values() + if t.parent_uid and t.parent_uid not in index.tasks + ] + if orphans: + print(f"\nWARNING: {len(orphans)} tasks reference a missing parent") + + conflicts = sorted( + path.relative_to(index.vault).as_posix() + for path in index.vault.rglob("*") + if path.is_file() + and matches_any(path.relative_to(index.vault).as_posix(), CONFLICT_GLOBS) + ) + if conflicts: + print(f"\nWARNING: {len(conflicts)} sync-conflict files present:") + for rel in conflicts[:10]: + print(f" {rel}") + print(" these are excluded from indexing; merge and delete them") + + dupes: dict[tuple[str, str], int] = {} + for task in index.tasks.values(): + key = (task.source.rel_path, task.title.lower()) + dupes[key] = dupes.get(key, 0) + 1 + ambiguous = {k: v for k, v in dupes.items() if v > 1} + if ambiguous: + print(f"\n{len(ambiguous)} ambiguous titles (same file, same text):") + for (rel, title), count in sorted(ambiguous.items())[:10]: + print(f" {rel}: {title!r} x{count}") + print(" identity for these relies on position; edits may reassign UIDs") + + index.close() + return 0 + + +def cmd_serve(args) -> int: + from radicale import config as radicale_config + from radicale import server + + cfg = _load_config(args) + settings = { + "server": {"hosts": args.host}, + "storage": {"type": "mdcaldav.storage"}, + "auth": {"type": "none" if args.no_auth else "htpasswd"}, + "logging": {"level": "info"}, + } + if not args.no_auth: + settings["auth"]["htpasswd_filename"] = args.htpasswd + settings["auth"]["htpasswd_encryption"] = "bcrypt" + + from . import storage as storage_module + + cfg.vault.watch = not args.no_watch + storage_module.set_config(cfg) # Radicale's schema rejects unknown keys + configuration = radicale_config.load() + configuration.update( + {section: {k: str(v) for k, v in values.items()} for section, values in settings.items()}, + "cli", + ) + + print(f"serving {cfg.vault.path} on http://{args.host}/", file=sys.stderr) + server.serve(configuration, None) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="mdcaldav", description=__doc__) + parser.add_argument("--config", help="path to config.toml") + parser.add_argument("--vault", help="override the vault path") + sub = parser.add_subparsers(dest="command", required=True) + + scan = sub.add_parser("scan", help="index the vault and print the task tree") + scan.set_defaults(func=cmd_scan) + + doctor = sub.add_parser("doctor", help="report index health and ambiguities") + doctor.set_defaults(func=cmd_doctor) + + serve = sub.add_parser("serve", help="run the CalDAV server") + serve.add_argument("--host", default="localhost:5232") + serve.add_argument("--htpasswd", default="~/.config/markdown-to-caldav/users") + serve.add_argument("--no-auth", action="store_true", help="disable auth (localhost only)") + serve.add_argument( + "--no-watch", action="store_true", help="do not watch the vault for changes" + ) + serve.set_defaults(func=cmd_serve) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/mdcaldav/collections.py b/src/mdcaldav/collections.py new file mode 100644 index 0000000..18a6057 --- /dev/null +++ b/src/mdcaldav/collections.py @@ -0,0 +1,57 @@ +"""Vault → CalDAV collection grouping strategies (SPEC 5).""" + +from __future__ import annotations + +import re +from pathlib import PurePosixPath + +from .config import Config +from .model import Task + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def slugify(value: str) -> str: + slug = _SLUG_RE.sub("-", value.strip().lower()).strip("-") + return slug or "untitled" + + +class Grouping: + """Maps tasks to collection hrefs and back to display names.""" + + def __init__(self, cfg: Config) -> None: + self.cfg = cfg + self.mode = cfg.collections.group_by + self._names: dict[str, str] = {} + + def collection_for(self, task: Task) -> str: + href, name = self._resolve(task) + self._names.setdefault(href, name) + return href + + def _resolve(self, task: Task) -> tuple[str, str]: + path = PurePosixPath(task.source.rel_path) + match self.mode: + case "single": + return "vault", "Vault" + case "file": + name = path.stem + return slugify(str(path.with_suffix(""))), name + case "heading": + name = task.heading_path[0] if task.heading_path else "Untitled" + return slugify(name), name + case "tag": + name = task.categories[0] if task.categories else "untagged" + return slugify(name), name + case _: # "directory" + parts = path.parts[: self.cfg.collections.depth] + if len(path.parts) <= self.cfg.collections.depth: + parts = path.parts[:-1] # a file at/above depth → its parent + name = "/".join(parts) if parts else "root" + return slugify(name), name + + def display_name(self, href: str) -> str: + return self._names.get(href, href) + + def known(self) -> list[str]: + return sorted(self._names) diff --git a/src/mdcaldav/config.py b/src/mdcaldav/config.py new file mode 100644 index 0000000..a808f4d --- /dev/null +++ b/src/mdcaldav/config.py @@ -0,0 +1,165 @@ +"""TOML configuration loading (SPEC 10).""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +from .model import Status + +DEFAULT_STATES: dict[str, Status] = { + " ": Status.NEEDS_ACTION, + "x": Status.COMPLETED, + "X": Status.COMPLETED, + "/": Status.IN_PROCESS, + ">": Status.IN_PROCESS, + "-": Status.CANCELLED, +} + +DEFAULT_EXCLUDE = [ + ".git/**", + ".zk/**", + ".obsidian/**", + "assets/**", + # Syncthing. `.stversions/` holds OLD COPIES of your notes — indexing it + # duplicates every task once per retained version. Conflict files are whole + # duplicate notes; the tmp patterns are partial in-flight writes. + ".stversions/**", + ".stfolder/**", + "**/*.sync-conflict-*", + "**/.syncthing.*", + "**/~syncthing~*", +] + +# Files that indicate a sync conflict the user should resolve by hand. +CONFLICT_GLOBS = ["**/*.sync-conflict-*"] + +# RFC 5545 3.8.1.9 bands: 1-4 high, 5 medium, 6-9 low. +DEFAULT_PRIORITY_MAP = {"A": 1, "B": 5, "C": 9} + + +@dataclass +class VaultConfig: + path: Path = Path("~/notes") + include: list[str] = field(default_factory=lambda: ["**/*.md"]) + exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE)) + tab_width: int = 4 + poll_interval: float | None = None + # Correctness comes from refreshing on read; watching only moves rescan + # cost off the request path. Enabled by `serve`, off elsewhere. + watch: bool = False + + +@dataclass +class CollectionsConfig: + group_by: str = "directory" + depth: int = 1 + + +@dataclass +class TasksConfig: + states: dict[str, Status] = field(default_factory=lambda: dict(DEFAULT_STATES)) + priority_map: dict[str, int] = field( + default_factory=lambda: dict(DEFAULT_PRIORITY_MAP) + ) + due_syntax: list[str] = field(default_factory=list) + + def marker_status(self, marker: str) -> Status: + """Unknown markers degrade to NEEDS-ACTION but keep their character.""" + return self.states.get(marker, Status.NEEDS_ACTION) + + def status_marker(self, status: Status, previous: str | None = None) -> str: + """Pick the marker char for a status, preferring to keep the existing one.""" + if previous is not None and self.marker_status(previous) is status: + return previous + for char, mapped in self.states.items(): + if mapped is status: + return char + return " " + + def priority_letter(self, priority: int | None) -> str | None: + """Map an RFC 5545 priority back to a letter by band (SPEC 4).""" + if not priority: + return None + best, best_dist = None, None + for letter, value in self.priority_map.items(): + dist = abs(value - priority) + if best_dist is None or dist < best_dist: + best, best_dist = letter, dist + return best + + +@dataclass +class WriteConfig: + allow_create: bool = True + allow_delete: bool = True + inbox: str = "inbox.md" + inbox_heading: str = "## Inbox" + delete_children: str = "cascade" # cascade | orphan | reject + backup_dir: Path | None = None + + +@dataclass +class IndexConfig: + db: Path = Path("~/.cache/markdown-to-caldav/index.db") + similarity_threshold: float = 0.75 + tombstone_retention: int = 1000 + + +@dataclass +class Config: + vault: VaultConfig = field(default_factory=VaultConfig) + collections: CollectionsConfig = field(default_factory=CollectionsConfig) + tasks: TasksConfig = field(default_factory=TasksConfig) + write: WriteConfig = field(default_factory=WriteConfig) + index: IndexConfig = field(default_factory=IndexConfig) + + @classmethod + def load(cls, path: str | Path) -> Config: + with open(path, "rb") as fh: + return cls.from_dict(tomllib.load(fh)) + + @classmethod + def from_dict(cls, data: dict) -> Config: + cfg = cls() + + if v := data.get("vault"): + cfg.vault = VaultConfig( + path=Path(v.get("path", "~/notes")).expanduser(), + include=v.get("include", ["**/*.md"]), + exclude=v.get("exclude", list(DEFAULT_EXCLUDE)), + tab_width=v.get("tab_width", 4), + poll_interval=v.get("poll_interval"), + watch=v.get("watch", False), + ) + if c := data.get("collections"): + cfg.collections = CollectionsConfig( + group_by=c.get("group_by", "directory"), depth=c.get("depth", 1) + ) + if t := data.get("tasks"): + states = dict(DEFAULT_STATES) + if raw := t.get("states"): + states = {k: Status(v) for k, v in raw.items()} + cfg.tasks = TasksConfig( + states=states, + priority_map=t.get("priority_map", dict(DEFAULT_PRIORITY_MAP)), + due_syntax=t.get("due_syntax", []), + ) + if w := data.get("write"): + backup = w.get("backup_dir") + cfg.write = WriteConfig( + allow_create=w.get("allow_create", True), + allow_delete=w.get("allow_delete", True), + inbox=w.get("inbox", "inbox.md"), + inbox_heading=w.get("inbox_heading", "## Inbox"), + delete_children=w.get("delete_children", "cascade"), + backup_dir=Path(backup).expanduser() if backup else None, + ) + if i := data.get("index"): + cfg.index = IndexConfig( + db=Path(i.get("db", IndexConfig.db)).expanduser(), + similarity_threshold=i.get("similarity_threshold", 0.75), + tombstone_retention=i.get("tombstone_retention", 1000), + ) + return cfg diff --git a/src/mdcaldav/globs.py b/src/mdcaldav/globs.py new file mode 100644 index 0000000..b94081c --- /dev/null +++ b/src/mdcaldav/globs.py @@ -0,0 +1,57 @@ +"""Path glob matching with real `**` semantics. + +`fnmatch` is wrong for path patterns: its `*` crosses `/`, so `*.tmp` silently +matches `a/b.tmp` while `**/*.md` fails to match a top-level `README.md`. Both +behaviours bite on a real vault, so patterns are compiled properly here. + + `**/` zero or more complete path segments + `**` anything, including separators + `*` anything except `/` + `?` one character except `/` +""" + +from __future__ import annotations + +import re +from functools import lru_cache + + +@lru_cache(maxsize=512) +def _compile(pattern: str) -> re.Pattern[str]: + out: list[str] = [] + i, n = 0, len(pattern) + while i < n: + if pattern.startswith("**/", i): + out.append("(?:[^/]+/)*") + i += 3 + elif pattern.startswith("**", i): + out.append(".*") + i += 2 + elif pattern[i] == "*": + out.append("[^/]*") + i += 1 + elif pattern[i] == "?": + out.append("[^/]") + i += 1 + elif pattern[i] == "[": + end = pattern.find("]", i + 1) + if end == -1: + out.append(re.escape(pattern[i])) + i += 1 + else: + body = pattern[i + 1 : end] + body = body.replace("\\", "\\\\") + out.append(f"[{'^' + body[1:] if body.startswith('!') else body}]") + i = end + 1 + else: + out.append(re.escape(pattern[i])) + i += 1 + return re.compile("".join(out) + r"\Z") + + +def matches(path: str, pattern: str) -> bool: + return _compile(pattern).match(path) is not None + + +def matches_any(path: str, patterns: list[str]) -> bool: + return any(matches(path, p) for p in patterns) diff --git a/src/mdcaldav/ical.py b/src/mdcaldav/ical.py new file mode 100644 index 0000000..b23617a --- /dev/null +++ b/src/mdcaldav/ical.py @@ -0,0 +1,199 @@ +"""Task ⇄ VTODO text (SPEC 4). + +We emit iCalendar text and hand it to `radicale.item.Item(text=...)`, which +parses lazily with vobject — so no second iCalendar library is needed. +""" + +from __future__ import annotations + +from datetime import date, datetime, timezone + +from .config import Config +from .model import Status, Task + +PRODID = "-//markdown-to-caldav//EN" + + +def escape(value: str) -> str: + """RFC 5545 3.3.11 TEXT escaping.""" + return ( + value.replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + ) + + +def unescape(value: str) -> str: + out, chars = [], iter(value) + for ch in chars: + if ch != "\\": + out.append(ch) + continue + nxt = next(chars, "") + out.append({"n": "\n", "N": "\n"}.get(nxt, nxt)) + return "".join(out) + + +def fold(line: str) -> str: + """RFC 5545 3.1 content-line folding at 75 octets, never splitting a character.""" + data = line.encode("utf-8") + if len(data) <= 75: + return line + chunks, start = [], 0 + limit = 75 + while start < len(data): + end = min(start + limit, len(data)) + while end > start and end < len(data) and (data[end] & 0xC0) == 0x80: + end -= 1 # back off to a character boundary + chunks.append(data[start:end].decode("utf-8")) + start = end + limit = 74 # continuation lines carry a leading space + return "\r\n ".join(chunks) + + +def _stamp(moment: datetime) -> str: + return moment.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def to_ics( + task: Task, + *, + last_modified: datetime | None = None, + completed_at: datetime | None = None, +) -> str: + """Serialize one task as a standalone VCALENDAR containing a VTODO.""" + now = last_modified or datetime.now(timezone.utc) + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + f"PRODID:{PRODID}", + "BEGIN:VTODO", + f"UID:{task.uid}", + f"DTSTAMP:{_stamp(now)}", + f"LAST-MODIFIED:{_stamp(now)}", + f"SUMMARY:{escape(task.title)}", + f"STATUS:{task.status.value}", + ] + if task.priority: + lines.append(f"PRIORITY:{task.priority}") + if task.description: + lines.append(f"DESCRIPTION:{escape(task.description)}") + if task.categories: + lines.append("CATEGORIES:" + ",".join(escape(c) for c in task.categories)) + if task.due: + lines.append(f"DUE;VALUE=DATE:{task.due.strftime('%Y%m%d')}") + if task.parent_uid: + lines.append(f"RELATED-TO;RELTYPE=PARENT:{task.parent_uid}") + if task.status is Status.COMPLETED: + lines.append("PERCENT-COMPLETE:100") + if completed_at: + lines.append(f"COMPLETED:{_stamp(completed_at)}") + lines.append(f"X-MD-SOURCE-FILE:{escape(task.source.rel_path)}") + lines.append(f"X-MD-SOURCE-LINE:{task.source.line_no + 1}") + lines += ["END:VTODO", "END:VCALENDAR"] + return "\r\n".join(fold(line) for line in lines) + "\r\n" + + +def unfold(text: str) -> list[str]: + out: list[str] = [] + for raw in text.replace("\r\n", "\n").split("\n"): + if raw[:1] in (" ", "\t") and out: + out[-1] += raw[1:] + else: + out.append(raw) + return out + + +def from_ics(text: str) -> dict: + """Extract the properties we model from a client-submitted VTODO. + + Unmodelled properties (RRULE, VALARM, ...) are ignored rather than rejected, + so clients that round-trip their full object do not fail (SPEC 7.3). + """ + fields: dict = {} + in_todo = False + in_alarm = False + for line in unfold(text): + stripped = line.strip() + if stripped == "BEGIN:VTODO": + in_todo = True + continue + if stripped == "END:VTODO": + in_todo = False + continue + if stripped.startswith("BEGIN:VALARM"): + in_alarm = True + continue + if stripped.startswith("END:VALARM"): + in_alarm = False + continue + if not in_todo or in_alarm or ":" not in stripped: + continue + + name_part, _, value = stripped.partition(":") + name, *params = name_part.split(";") + name = name.upper() + + if name == "UID": + fields["uid"] = value + elif name == "SUMMARY": + fields["title"] = unescape(value) + elif name == "DESCRIPTION": + fields["description"] = unescape(value) or None + elif name == "STATUS": + try: + fields["status"] = Status(value.upper()) + except ValueError: + pass + elif name == "PRIORITY": + try: + fields["priority"] = int(value) + except ValueError: + pass + elif name == "PERCENT-COMPLETE": + try: + fields["percent_complete"] = int(value) + except ValueError: + pass + elif name == "COMPLETED": + fields["completed_at"] = value + elif name == "CATEGORIES": + fields["categories"] = [unescape(c) for c in value.split(",") if c] + elif name == "DUE": + fields["due"] = _parse_due(value) + elif name == "RELATED-TO": + if any(p.upper() == "RELTYPE=PARENT" for p in params) or not params: + fields["parent_uid"] = value + + # Some clients signal completion only via PERCENT-COMPLETE. + if fields.get("percent_complete") == 100 and "status" not in fields: + fields["status"] = Status.COMPLETED + return fields + + +def _parse_due(value: str) -> date | None: + for fmt in ("%Y%m%d", "%Y%m%dT%H%M%SZ", "%Y%m%dT%H%M%S"): + try: + return datetime.strptime(value, fmt).date() + except ValueError: + continue + return None + + +def apply_to_task(task: Task, fields: dict, cfg: Config) -> dict: + """Return the subset of `fields` that actually differs from `task`.""" + changes: dict = {} + if "title" in fields and fields["title"] != task.title: + changes["title"] = fields["title"] + if "status" in fields and fields["status"] is not task.status: + changes["status"] = fields["status"] + if "priority" in fields: + letter = cfg.tasks.priority_letter(fields["priority"] or None) + new = cfg.tasks.priority_map.get(letter) if letter else None + if new != task.priority: + changes["priority"] = new + if "description" in fields and fields["description"] != task.description: + changes["description"] = fields["description"] + return changes diff --git a/src/mdcaldav/index.py b/src/mdcaldav/index.py new file mode 100644 index 0000000..f836a79 --- /dev/null +++ b/src/mdcaldav/index.py @@ -0,0 +1,431 @@ +"""SQLite sidecar: stable UIDs, sync generations, tombstones (SPEC 6, 8.1). + +The index is a derived cache. Deleting the database and rescanning is always +safe; the cost is that every UID is regenerated, so clients see a full +replacement of their task list. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from rapidfuzz import fuzz + +from .collections import Grouping +from .config import Config +from .globs import matches_any +from .model import Status, Task, normalize_title +from .parser import parse + +SCHEMA_VERSION = 1 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS meta (schema_version INT, generation INT); +CREATE TABLE IF NOT EXISTS files ( + rel_path TEXT PRIMARY KEY, mtime REAL, size INT, hash TEXT, last_scan_gen INT); +CREATE TABLE IF NOT EXISTS tasks ( + uid TEXT PRIMARY KEY, + collection TEXT, rel_path TEXT, + heading_path TEXT, group_path TEXT, + sibling_index INT, depth INT, + title_norm TEXT, content_hash TEXT, + status TEXT, completed_at TEXT, + parent_uid TEXT, + first_seen_gen INT, last_modified_gen INT, deleted_gen INT); +CREATE INDEX IF NOT EXISTS tasks_lookup ON tasks(rel_path, heading_path, title_norm); +CREATE INDEX IF NOT EXISTS tasks_sync ON tasks(last_modified_gen); +CREATE INDEX IF NOT EXISTS tasks_deleted ON tasks(deleted_gen); +""" + + +@dataclass +class _Row: + uid: str + heading_path: str + group_path: str + sibling_index: int + depth: int + title_norm: str + content_hash: str + status: str + completed_at: str | None + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def file_hash(data: bytes) -> str: + return hashlib.blake2b(data, digest_size=16).hexdigest() + + +class Index: + def __init__(self, cfg: Config) -> None: + self.cfg = cfg + self.vault = Path(cfg.vault.path).expanduser() + self.grouping = Grouping(cfg) + self.tasks: dict[str, Task] = {} # uid → current task (in-memory truth) + self._parsed: dict[str, list[Task]] = {} # rel_path → tasks + + db_path = Path(cfg.index.db).expanduser() + db_path.parent.mkdir(parents=True, exist_ok=True) + self.db = sqlite3.connect(db_path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.executescript(SCHEMA) + if self.db.execute("SELECT COUNT(*) FROM meta").fetchone()[0] == 0: + self.db.execute( + "INSERT INTO meta (schema_version, generation) VALUES (?, 0)", + (SCHEMA_VERSION,), + ) + self.db.commit() + + # ---- generations ----------------------------------------------------- + + @property + def generation(self) -> int: + return self.db.execute("SELECT generation FROM meta").fetchone()[0] + + def _bump(self) -> int: + gen = self.generation + 1 + self.db.execute("UPDATE meta SET generation = ?", (gen,)) + return gen + + # ---- scanning -------------------------------------------------------- + + def included(self, rel: str) -> bool: + """Whether a vault-relative path should be indexed at all. + + Applied to targeted rescans as well as full scans — otherwise a watcher + event for an excluded path (Syncthing's `.stversions/`, which holds old + copies of your notes) would index it and duplicate every task. + """ + if matches_any(rel, self.cfg.vault.exclude): + return False + return matches_any(rel, self.cfg.vault.include) + + def discover_files(self) -> list[str]: + return sorted( + rel + for path in self.vault.rglob("*.md") + if self.included(rel := path.relative_to(self.vault).as_posix()) + ) + + def rescan(self, only: list[str] | None = None) -> int: + """Reparse the vault (or `only` these files) and reconcile identities.""" + gen = self._bump() + targets = ( + [rel for rel in only if self.included(rel)] + if only is not None + else self.discover_files() + ) + present = set(self.discover_files()) + + pending_new: list[Task] = [] + for rel in targets: + path = self.vault / rel + if not path.exists(): + self._retire_file(rel, gen) + continue + data = path.read_bytes() + parsed = parse(data, rel, self.cfg) + pending_new.extend(self._reconcile(rel, parsed, gen)) + stat = path.stat() + self.db.execute( + "INSERT OR REPLACE INTO files VALUES (?,?,?,?,?)", + (rel, stat.st_mtime, stat.st_size, file_hash(data), gen), + ) + + if only is None: + for row in self.db.execute("SELECT rel_path FROM files").fetchall(): + if row["rel_path"] not in present: + self._retire_file(row["rel_path"], gen) + + self._resolve_moves(pending_new, gen) + self._prune_tombstones(gen) + self.db.commit() + self._rebuild_memory() + return gen + + def refresh_if_stale(self) -> bool: + """Rescan only files whose mtime/size no longer match the index. + + Called on every read path so an edit made in vim is visible even when no + watcher is running; the watcher (SPEC 8) only improves latency. + """ + known = { + row["rel_path"]: (row["mtime"], row["size"]) + for row in self.db.execute("SELECT rel_path, mtime, size FROM files") + } + stale: list[str] = [] + for rel in self.discover_files(): + path = self.vault / rel + try: + stat = path.stat() + except OSError: + continue + if known.pop(rel, None) != (stat.st_mtime, stat.st_size): + stale.append(rel) + stale.extend(known) # files that disappeared + if not stale: + return False + self.rescan(stale) + return True + + def reassign_uid(self, old_uid: str, new_uid: str) -> None: + """Adopt a client-chosen UID for a task we just created.""" + if old_uid == new_uid: + return + self.db.execute("DELETE FROM tasks WHERE uid = ?", (new_uid,)) + self.db.execute("UPDATE tasks SET uid = ? WHERE uid = ?", (new_uid, old_uid)) + self.db.execute( + "UPDATE tasks SET parent_uid = ? WHERE parent_uid = ?", (new_uid, old_uid) + ) + self.db.commit() + for tasks in self._parsed.values(): + for task in tasks: + if task.uid == old_uid: + task.uid = new_uid + if task.parent_uid == old_uid: + task.parent_uid = new_uid + task.children = [new_uid if c == old_uid else c for c in task.children] + self._rebuild_memory() + + def _retire_file(self, rel: str, gen: int) -> None: + self.db.execute( + "UPDATE tasks SET deleted_gen = ? WHERE rel_path = ? AND deleted_gen IS NULL", + (gen, rel), + ) + self.db.execute("DELETE FROM files WHERE rel_path = ?", (rel,)) + self._parsed.pop(rel, None) + + # ---- identity -------------------------------------------------------- + + def _reconcile(self, rel: str, parsed: list[Task], gen: int) -> list[Task]: + """Assign stable UIDs to `parsed`, returning those that look brand new.""" + rows = [ + _Row( + r["uid"], r["heading_path"], r["group_path"], r["sibling_index"], + r["depth"], r["title_norm"], r["content_hash"], r["status"], + r["completed_at"], + ) + for r in self.db.execute( + "SELECT * FROM tasks WHERE rel_path = ? AND deleted_gen IS NULL", (rel,) + ) + ] + unclaimed = {row.uid: row for row in rows} + local_to_uid: dict[str, str] = {} + fresh: list[Task] = [] + + for task in parsed: + heading = json.dumps(list(task.heading_path)) + group = json.dumps(list(task.group_path)) + norm = normalize_title(task.title) + + match = self._match_exact(unclaimed, heading, group, norm) + if match is None: + match = self._match_positional(unclaimed, heading, task, norm) + + if match is not None: + del unclaimed[match.uid] + local_to_uid[task.uid] = match.uid + task.uid = match.uid + else: + # Mint a real UID now so intra-file parent links resolve; + # _resolve_moves may still swap it for a moved task's old UID. + new_uid = str(uuid.uuid4()) + local_to_uid[task.uid] = new_uid + task.uid = new_uid + fresh.append(task) + + # Re-point parent links from provisional local ids to resolved UIDs. + for task in parsed: + if task.parent_uid: + task.parent_uid = local_to_uid.get(task.parent_uid, task.parent_uid) + task.children = [local_to_uid.get(c, c) for c in task.children] + task.collection = self.grouping.collection_for(task) + + fresh_ids = {id(t) for t in fresh} + for task in parsed: + if id(task) not in fresh_ids: + self._persist(task, rel, gen, is_new=False) + + for row in unclaimed.values(): + self.db.execute( + "UPDATE tasks SET deleted_gen = ? WHERE uid = ?", (gen, row.uid) + ) + + self._parsed[rel] = parsed + return fresh + + @staticmethod + def _match_exact( + unclaimed: dict[str, _Row], heading: str, group: str, norm: str + ) -> _Row | None: + for row in unclaimed.values(): + if ( + row.heading_path == heading + and row.group_path == group + and row.title_norm == norm + ): + return row + return None + + def _match_positional( + self, unclaimed: dict[str, _Row], heading: str, task: Task, norm: str + ) -> _Row | None: + threshold = self.cfg.index.similarity_threshold * 100 + best, best_score = None, threshold + for row in unclaimed.values(): + if ( + row.heading_path != heading + or row.depth != task.depth + or row.sibling_index != task.sibling_index + ): + continue + score = fuzz.ratio(row.title_norm, norm) + if score >= best_score: + best, best_score = row, score + return best + + def _resolve_moves(self, fresh: list[Task], gen: int) -> None: + """Rule 3: reuse a UID only when the original disappeared this scan. + + The disappearance requirement is what stops carry-forward duplicates + (the same title copied into a new daily note) from being read as a move + and silently merging two independent tasks (SPEC 6.3). + """ + tombstoned = self.db.execute( + "SELECT uid, title_norm, rel_path FROM tasks WHERE deleted_gen = ?", (gen,) + ).fetchall() + by_title: dict[str, list[sqlite3.Row]] = {} + for row in tombstoned: + by_title.setdefault(row["title_norm"], []).append(row) + + claimed: set[str] = set() + for task in fresh: + norm = normalize_title(task.title) + candidates = [r for r in by_title.get(norm, []) if r["uid"] not in claimed] + if len(candidates) == 1: # unique vault-wide, or it is not a move + old = candidates[0]["uid"] + claimed.add(old) + self.db.execute("DELETE FROM tasks WHERE uid = ?", (old,)) + self._repoint(task.source.rel_path, task.uid, old) + task.uid = old + self._persist(task, task.source.rel_path, gen, is_new=True) + + def _repoint(self, rel: str, from_uid: str, to_uid: str) -> None: + for task in self._parsed.get(rel, []): + if task.parent_uid == from_uid: + task.parent_uid = to_uid + task.children = [to_uid if c == from_uid else c for c in task.children] + + def _persist(self, task: Task, rel: str, gen: int, *, is_new: bool) -> None: + prior = self.db.execute( + "SELECT status, completed_at, content_hash, last_modified_gen," + " first_seen_gen FROM tasks WHERE uid = ?", + (task.uid,), + ).fetchone() + + completed_at = prior["completed_at"] if prior else None + if task.status is Status.COMPLETED: + if not prior or prior["status"] != Status.COMPLETED.value: + completed_at = _now() # observed the transition + else: + completed_at = None + + changed = ( + is_new + or prior is None + or prior["content_hash"] != task.content_hash + or prior["status"] != task.status.value + ) + last_gen = gen if changed else prior["last_modified_gen"] + + self.db.execute( + "INSERT OR REPLACE INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + task.uid, + task.collection, + rel, + json.dumps(list(task.heading_path)), + json.dumps(list(task.group_path)), + task.sibling_index, + task.depth, + normalize_title(task.title), + task.content_hash, + task.status.value, + completed_at, + task.parent_uid, + prior["first_seen_gen"] if prior else gen, + last_gen, + None, + ), + ) + + def _prune_tombstones(self, gen: int) -> None: + cutoff = gen - self.cfg.index.tombstone_retention + self.db.execute( + "DELETE FROM tasks WHERE deleted_gen IS NOT NULL AND deleted_gen < ?", + (cutoff,), + ) + + # ---- serving --------------------------------------------------------- + + def _rebuild_memory(self) -> None: + self.tasks = {} + for tasks in self._parsed.values(): + for task in tasks: + self.tasks[task.uid] = task + + def completed_at(self, uid: str) -> datetime | None: + row = self.db.execute( + "SELECT completed_at FROM tasks WHERE uid = ?", (uid,) + ).fetchone() + if row and row["completed_at"]: + return datetime.fromisoformat(row["completed_at"]) + return None + + def collections(self) -> list[str]: + rows = self.db.execute( + "SELECT DISTINCT collection FROM tasks WHERE deleted_gen IS NULL" + ).fetchall() + return sorted(r["collection"] for r in rows if r["collection"]) + + def tasks_in(self, collection: str) -> list[Task]: + return [t for t in self.tasks.values() if t.collection == collection] + + def rel_path_of(self, uid: str) -> str | None: + row = self.db.execute( + "SELECT rel_path FROM tasks WHERE uid = ?", (uid,) + ).fetchone() + return row["rel_path"] if row else None + + def sync(self, collection: str, old_token: str = "") -> tuple[str, list[str]]: + """Radicale sync-token contract (SPEC 8.1).""" + gen = self.generation + token = f"http://mdcaldav.local/ns/sync/{gen}" + if not old_token: + return token, [f"{t.uid}.ics" for t in self.tasks_in(collection)] + + try: + old_gen = int(old_token.rsplit("/", 1)[1]) + except (IndexError, ValueError): + raise ValueError("Malformed sync token") + if old_gen > gen or old_gen < gen - self.cfg.index.tombstone_retention: + raise ValueError("Sync token is too old") + + rows = self.db.execute( + "SELECT uid FROM tasks WHERE collection = ?" + " AND (last_modified_gen > ? OR deleted_gen > ?)", + (collection, old_gen, old_gen), + ).fetchall() + return token, [f"{r['uid']}.ics" for r in rows] + + def close(self) -> None: + self.db.close() diff --git a/src/mdcaldav/model.py b/src/mdcaldav/model.py new file mode 100644 index 0000000..698a3dd --- /dev/null +++ b/src/mdcaldav/model.py @@ -0,0 +1,74 @@ +"""Core data types shared across the parser, index, writer and CalDAV layers.""" + +from __future__ import annotations + +import enum +import hashlib +from dataclasses import dataclass, field +from datetime import date + + +class Status(enum.Enum): + NEEDS_ACTION = "NEEDS-ACTION" + COMPLETED = "COMPLETED" + IN_PROCESS = "IN-PROCESS" + CANCELLED = "CANCELLED" + + +@dataclass(frozen=True) +class Source: + """Where a task lives, precisely enough to rewrite it in place. + + All spans are absolute byte offsets into the file. Byte offsets rather than + character offsets so the writer can operate on raw bytes and guarantee that + untouched regions are preserved exactly (SPEC 7.2). + """ + + rel_path: str + line_no: int # 0-based + line_span: tuple[int, int] # full line, excluding its line ending + marker_span: tuple[int, int] # the single state char inside [ ] + title_span: tuple[int, int] + prio_span: tuple[int, int] | None # the "[#A]" token, when present + indent: str + bullet: str + + +@dataclass +class Task: + uid: str + title: str + status: Status + raw_marker: str + source: Source + priority: int | None = None + description: str | None = None + due: date | None = None + categories: list[str] = field(default_factory=list) + parent_uid: str | None = None + children: list[str] = field(default_factory=list) + heading_path: tuple[str, ...] = () + group_path: tuple[str, ...] = () + sibling_index: int = 0 + depth: int = 0 + collection: str = "" + # Byte span covering the description bullet lines, and the indent they use. + # Both None when the task currently has no description. + description_span: tuple[int, int] | None = None + description_indent: str | None = None + + @property + def content_hash(self) -> str: + """Identity of the task's own line content, used for conflict detection.""" + return content_hash(self.title, self.raw_marker, self.priority) + + +def content_hash(title: str, raw_marker: str, priority: int | None) -> str: + h = hashlib.blake2b(digest_size=16) + h.update(f"{raw_marker}\x00{priority}\x00{title}".encode()) + return h.hexdigest() + + +def normalize_title(title: str) -> str: + """Fuzzy-match key: case- and whitespace-insensitive.""" + return " ".join(title.lower().split()) diff --git a/src/mdcaldav/parser.py b/src/mdcaldav/parser.py new file mode 100644 index 0000000..9986a38 --- /dev/null +++ b/src/mdcaldav/parser.py @@ -0,0 +1,286 @@ +"""Markdown → task tree (SPEC 2). + +A hand-rolled line scanner rather than a CommonMark AST: write-back needs exact +byte spans for the checkbox marker, the priority token and the title so an edit +can touch only those bytes (SPEC 2.7). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path + +from .config import Config +from .model import Source, Task + +LIST_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<bullet>[-*+])(?P<gap>[ \t]+)(?P<rest>.*)$") +CHECKBOX_RE = re.compile(r"^\[(?P<mark>[^\]])\](?:(?P<gap>[ \t]+)(?P<rest>.*)|$)") +PRIO_RE = re.compile(r"^(?P<tok>\[#(?P<letter>[A-Za-z])\])(?P<gap>[ \t]*)(?P<rest>.*)$") +HEADING_RE = re.compile(r"^(?P<hashes>#{1,6})[ \t]+(?P<text>.*?)[ \t]*#*[ \t]*$") +FENCE_RE = re.compile(r"^[ \t]*(?P<fence>`{3,}|~{3,})(?P<info>.*)$") +TAG_RE = re.compile(r"(?<!\S)#(?P<tag>[A-Za-z][\w/-]*)") +DUE_PATTERNS = [ + re.compile(r"@due\((?P<d>\d{4}-\d{2}-\d{2})\)"), + re.compile(r"📅[ \t]*(?P<d>\d{4}-\d{2}-\d{2})"), +] + + +@dataclass +class _Node: + indent_w: int + line_no: int + title: str + has_checkbox: bool + marker: str | None + priority: int | None + heading_path: tuple[str, ...] + source: Source | None + children: list[_Node] = field(default_factory=list) + + def has_task_descendant(self) -> bool: + return any(c.has_checkbox or c.has_task_descendant() for c in self.children) + + +def _expand_indent(indent: str, tab_width: int) -> int: + width = 0 + for ch in indent: + width += tab_width - (width % tab_width) if ch == "\t" else 1 + return width + + +def _split_lines(data: bytes) -> list[tuple[str, int, int]]: + """Return (text, start_byte, end_byte) per line; spans exclude line endings.""" + out: list[tuple[str, int, int]] = [] + offset = 0 + for raw in data.splitlines(keepends=True): + stripped = raw.rstrip(b"\r\n") + out.append((stripped.decode("utf-8", "replace"), offset, offset + len(stripped))) + offset += len(raw) + return out + + +def _b(text: str, char_index: int) -> int: + """Byte length of the first `char_index` characters.""" + return len(text[:char_index].encode("utf-8")) + + +def _extract_due(title: str, enabled: bool) -> tuple[str, date | None]: + if not enabled: + return title, None + for pattern in DUE_PATTERNS: + if m := pattern.search(title): + cleaned = (title[: m.start()] + title[m.end() :]).strip() + try: + return cleaned, date.fromisoformat(m.group("d")) + except ValueError: + return title, None + return title, None + + +def _scan(data: bytes, rel_path: str, cfg: Config) -> list[_Node]: + """Build the bullet tree, skipping frontmatter and code blocks (SPEC 2.6).""" + lines = _split_lines(data) + root = _Node(-1, -1, "", False, None, None, (), None) + stack: list[_Node] = [root] + headings: list[tuple[int, str]] = [] + fence: str | None = None + in_frontmatter = False + + for idx, (text, start, end) in enumerate(lines): + if idx == 0 and text.strip() == "---": + in_frontmatter = True + continue + if in_frontmatter: + if text.strip() in ("---", "..."): + in_frontmatter = False + continue + + if m := FENCE_RE.match(text): + token = m.group("fence") + if fence is None: + fence = token[0] * 3 + continue + if token.startswith(fence): + fence = None + continue + if fence is not None: + continue + + if m := HEADING_RE.match(text): + level = len(m.group("hashes")) + while headings and headings[-1][0] >= level: + headings.pop() + headings.append((level, m.group("text"))) + del stack[1:] # a heading ends any open list + continue + + # Indented code block: 4+ columns of indent with no list open. Inside a + # list the same indentation is item continuation, not code (SPEC 2.6). + leading = text[: len(text) - len(text.lstrip(" \t"))] + if ( + len(stack) == 1 + and text.strip() + and _expand_indent(leading, cfg.vault.tab_width) >= 4 + ): + continue + + m = LIST_RE.match(text) + if not m: + # A non-blank line at column 0 ends the current list. + if text.strip() and not text[:1].isspace(): + del stack[1:] + continue + + indent_w = _expand_indent(m.group("indent"), cfg.vault.tab_width) + rest = m.group("rest") + rest_at = len(m.group("indent")) + 1 + len(m.group("gap")) + + marker = None + priority = None + prio_span = None + marker_span = (0, 0) + + if cb := CHECKBOX_RE.match(rest): + marker = cb.group("mark") + mark_at = rest_at + 1 + marker_span = (start + _b(text, mark_at), start + _b(text, mark_at + 1)) + body = cb.group("rest") or "" + body_at = rest_at + (cb.end("gap") if cb.group("gap") else cb.end()) + if pm := PRIO_RE.match(body): + prio_span = ( + start + _b(text, body_at), + start + _b(text, body_at + len(pm.group("tok"))), + ) + letter = pm.group("letter").upper() + priority = cfg.tasks.priority_map.get(letter) + body_at += pm.end("gap") + body = pm.group("rest") + else: + body, body_at = rest, rest_at + + title = body.rstrip() + title_span = (start + _b(text, body_at), start + _b(text, body_at + len(title))) + + node = _Node( + indent_w=indent_w, + line_no=idx, + title=title, + has_checkbox=marker is not None, + marker=marker, + priority=priority, + heading_path=tuple(h for _, h in headings), + source=Source( + rel_path=rel_path, + line_no=idx, + line_span=(start, end), + marker_span=marker_span, + title_span=title_span, + prio_span=prio_span, + indent=m.group("indent"), + bullet=m.group("bullet"), + ), + ) + + while len(stack) > 1 and stack[-1].indent_w >= indent_w: + stack.pop() + stack[-1].children.append(node) + stack.append(node) + + return root.children + + +def _subtree_end(node: _Node) -> int: + """Byte offset just past the last line of this node's subtree.""" + assert node.source is not None + end = node.source.line_span[1] + for child in node.children: + end = max(end, _subtree_end(child)) + return end + + +def _describe(node: _Node, depth: int = 0) -> list[str]: + """Flatten a description subtree, preserving relative nesting.""" + lines = [" " * depth + node.title] + for child in node.children: + lines.extend(_describe(child, depth + 1)) + return lines + + +def parse(data: bytes, rel_path: str, cfg: Config) -> list[Task]: + """Parse one file into document-ordered tasks with provisional UIDs. + + UIDs are placeholders (`local:N`); `index.py` assigns the stable ones. + """ + tasks: list[Task] = [] + counter = 0 + + def walk( + nodes: list[_Node], + parent: Task | None, + group_path: tuple[str, ...], + depth: int, + ) -> None: + nonlocal counter + for node in nodes: + if node.has_checkbox: + assert node.source is not None and node.marker is not None + title, due = _extract_due(node.title, bool(cfg.tasks.due_syntax)) + desc_nodes = [ + child + for child in node.children + if not child.has_checkbox and not child.has_task_descendant() + ] + description = [ + line for child in desc_nodes for line in _describe(child) + ] + desc_span = None + desc_indent = None + if desc_nodes: + first = desc_nodes[0] + assert first.source is not None + desc_indent = first.source.indent + desc_span = (first.source.line_span[0], _subtree_end(desc_nodes[-1])) + task = Task( + uid=f"local:{counter}", + title=title, + status=cfg.tasks.marker_status(node.marker), + raw_marker=node.marker, + source=node.source, + priority=node.priority, + description="\n".join(description) or None, + due=due, + categories=[ + *node.heading_path, + *group_path, + *(m.group("tag") for m in TAG_RE.finditer(node.title)), + ], + parent_uid=parent.uid if parent else None, + heading_path=node.heading_path, + group_path=group_path, + depth=depth, + description_span=desc_span, + description_indent=desc_indent, + ) + counter += 1 + if parent: + parent.children.append(task.uid) + tasks.append(task) + walk(node.children, task, (), depth + 1) + elif node.has_task_descendant(): + # Group node: not a task, but its label is kept as context. + walk(node.children, parent, group_path + (node.title,), depth) + # else: description (handled above) or a stray note bullet — dropped. + + walk(_scan(data, rel_path, cfg), None, (), 0) + + seen: dict[str | None, int] = {} + for task in tasks: + seen[task.parent_uid] = seen.get(task.parent_uid, -1) + 1 + task.sibling_index = seen[task.parent_uid] + return tasks + + +def parse_file(path: Path, rel_path: str, cfg: Config) -> list[Task]: + return parse(path.read_bytes(), rel_path, cfg) diff --git a/src/mdcaldav/storage.py b/src/mdcaldav/storage.py new file mode 100644 index 0000000..cf4109c --- /dev/null +++ b/src/mdcaldav/storage.py @@ -0,0 +1,286 @@ +"""Radicale storage plugin (SPEC 9). + +Radicale loads storage via `load_plugin(..., "storage", "Storage", ...)`, so the +module must export a class named `Storage`. (Radicale 2.x wanted `Collection`; +that guidance does not apply to 3.x.) +""" + +from __future__ import annotations + +import contextlib +import os +import threading +from datetime import datetime, timezone +from email.utils import formatdate +from typing import Iterable, Iterator + +from radicale import item as radicale_item +from radicale import pathutils +from radicale.storage import BaseCollection, BaseStorage + +from .config import Config +from .ical import apply_to_task, from_ics, to_ics +from .index import Index +from .writer import ConflictError, NotAllowedError, Writer + +_override: Config | None = None + + +def set_config(cfg: Config) -> None: + """Inject the vault config before Radicale instantiates the plugin. + + Radicale validates its own config schema and rejects unknown keys, so the + config cannot ride along in `[storage]`. The CLI calls this directly; other + entry points can set MDCALDAV_CONFIG to a config.toml path. + """ + global _override + _override = cfg + + +def _resolve_config() -> Config: + if _override is not None: + return _override + if path := os.environ.get("MDCALDAV_CONFIG"): + return Config.load(path) + return Config() + + +def _http_date(moment: datetime) -> str: + return formatdate(moment.timestamp(), usegmt=True) + + +class Collection(BaseCollection): + def __init__(self, storage: "Storage", href: str, user: str) -> None: + self._storage = storage + self._href = href + self._user = user + + # ---- identity -------------------------------------------------------- + + @property + def path(self) -> str: + return f"{self._user}/{self._href}" + + @property + def owner(self) -> str: + return self._user + + @property + def tag(self) -> str: + return "VCALENDAR" + + @property + def is_principal(self) -> bool: + return False + + @property + def last_modified(self) -> str: + return _http_date(datetime.now(timezone.utc)) + + # ---- reading --------------------------------------------------------- + + def _item(self, uid: str) -> radicale_item.Item | None: + task = self._storage.index.tasks.get(uid) + if task is None or task.collection != self._href: + return None + text = to_ics(task, completed_at=self._storage.index.completed_at(uid)) + return radicale_item.Item(collection=self, href=f"{uid}.ics", text=text) + + def get_multi( + self, hrefs: Iterable[str] + ) -> Iterable[tuple[str, radicale_item.Item | None]]: + self._storage.index.refresh_if_stale() + for href in hrefs: + yield href, self._item(href.removesuffix(".ics")) + + def get_all(self) -> Iterable[radicale_item.Item]: + self._storage.index.refresh_if_stale() + for task in self._storage.index.tasks_in(self._href): + if (item := self._item(task.uid)) is not None: + yield item + + def get_filtered(self, filters) -> Iterable[tuple[radicale_item.Item, bool]]: + # Returning False lets Radicale apply the filter itself (SPEC 9.2). + for item in self.get_all(): + yield item, False + + def has_uid(self, uid: str) -> bool: + return uid in self._storage.index.tasks + + # ---- writing --------------------------------------------------------- + + def upload( + self, href: str, item: radicale_item.Item + ) -> tuple[radicale_item.Item, radicale_item.Item | None]: + index = self._storage.index + writer = self._storage.writer + fields = from_ics(item.serialize()) + uid = href.removesuffix(".ics") + + task = index.tasks.get(uid) + try: + if task is None: + fields.setdefault("uid", uid) + uid = writer.create(fields, collection=self._href) + else: + changes = apply_to_task(task, fields, self._storage.cfg) + writer.apply(uid, changes) + except (ConflictError, NotAllowedError) as exc: + raise ValueError(str(exc)) from exc + + uploaded = self._item(uid) + if uploaded is None: + raise ValueError(f"task {uid} vanished during upload") + return uploaded, None + + def delete(self, href: str | None = None) -> None: + if href is None: + raise ValueError("deleting a whole collection is not supported") + try: + self._storage.writer.delete(href.removesuffix(".ics")) + except (ConflictError, NotAllowedError, KeyError) as exc: + raise ValueError(str(exc)) from exc + + # ---- metadata -------------------------------------------------------- + + def get_meta(self, key: str | None = None): + meta = { + "tag": "VCALENDAR", + "D:displayname": self._storage.index.grouping.display_name(self._href), + "C:supported-calendar-component-set": "VTODO", + } + meta.update(self._storage.meta_overrides.get(self._href, {})) + return meta if key is None else meta.get(key) + + def set_meta(self, props) -> None: + # Markdown has nowhere to put display name or colour, so client-set + # properties live in memory alongside the index (SPEC 9.2). + self._storage.meta_overrides.setdefault(self._href, {}).update(props) + + def sync(self, old_token: str = "") -> tuple[str, Iterable[str]]: + self._storage.index.refresh_if_stale() + return self._storage.index.sync(self._href, old_token) + + +class Storage(BaseStorage): + def __init__(self, configuration) -> None: + super().__init__(configuration) + self.cfg: Config = _resolve_config() + self.user = "vault" + self.index = Index(self.cfg) + self.writer = Writer(self.cfg, self.index) + self.meta_overrides: dict[str, dict[str, str]] = {} + self._lock = threading.RLock() + self.index.rescan() + + self.watcher = None + if self.cfg.vault.watch: + from .watcher import Watcher + + self.watcher = Watcher(self.cfg, self.index) + self.watcher.start() + + # ---- discovery ------------------------------------------------------- + + def discover( + self, path: str, depth: str = "0", child_context_manager=None, user_groups=set() + ) -> Iterable: + self.index.refresh_if_stale() + attributes = pathutils.strip_path(path).split("/") if path.strip("/") else [] + + if not attributes: + # "/" is the root collection, whose path must be "" — Radicale drops + # any item whose path does not match the request, which silently + # breaks current-user-principal discovery. + yield Principal(self, "") + return + + # The vault is served under whichever principal the client authenticated + # as, so collection hrefs stay inside that principal's namespace. + user = attributes[0] + + if len(attributes) == 1: # "/<user>" → principal, optionally its children + yield Principal(self, user) + if depth != "0": + for href in self.index.collections(): + yield Collection(self, href, user) + return + + collection = Collection(self, attributes[1], user) + if len(attributes) == 2: # "/vault/<collection>" + yield collection + if depth != "0": + yield from collection.get_all() + return + + item = collection._item(attributes[2].removesuffix(".ics")) + if item is not None: + yield item + + def create_collection(self, href: str, items=None, props=None): + raise ValueError("collections mirror the vault and cannot be created") + + def move(self, item, to_collection, to_href) -> None: + raise ValueError("moving tasks between collections is not supported") + + @contextlib.contextmanager + def acquire_lock(self, mode: str, user: str = "", *args, **kwargs) -> Iterator[None]: + with self._lock: + yield + + def verify(self) -> bool: + return True + + +class Principal(BaseCollection): + """The user's home collection, or the root when `user` is empty. + + Holds the task lists but no items of its own. + """ + + def __init__(self, storage: Storage, user: str) -> None: + self._storage = storage + self._user = user + + @property + def path(self) -> str: + return self._user + + @property + def owner(self) -> str: + return self._user + + @property + def is_principal(self) -> bool: + return bool(self._user) # the root ("") is not a principal + + @property + def tag(self) -> str: + return "" + + @property + def last_modified(self) -> str: + return _http_date(datetime.now(timezone.utc)) + + def get_multi(self, hrefs): + for href in hrefs: + yield href, None + + def get_all(self): + return iter(()) + + def get_meta(self, key: str | None = None): + meta = {"D:displayname": self._user} + return meta if key is None else meta.get(key) + + def set_meta(self, props) -> None: + pass + + def upload(self, href, item): + raise ValueError("cannot upload to the principal collection") + + def delete(self, href: str | None = None) -> None: + raise ValueError("cannot delete the principal collection") + + def sync(self, old_token: str = ""): + return f"http://mdcaldav.local/ns/sync/{self._storage.index.generation}", [] diff --git a/src/mdcaldav/watcher.py b/src/mdcaldav/watcher.py new file mode 100644 index 0000000..290e9e1 --- /dev/null +++ b/src/mdcaldav/watcher.py @@ -0,0 +1,82 @@ +"""Filesystem watching with debounce (SPEC 8).""" + +from __future__ import annotations + +import threading +from pathlib import Path + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver + +from .config import Config +from .index import Index + +DEBOUNCE_SECONDS = 0.3 + + +class _Handler(FileSystemEventHandler): + def __init__(self, watcher: "Watcher") -> None: + self.watcher = watcher + + def on_any_event(self, event: FileSystemEvent) -> None: + if event.is_directory: + return + for raw in (getattr(event, "src_path", None), getattr(event, "dest_path", None)): + if raw and str(raw).endswith(".md"): + self.watcher.touch(Path(str(raw))) + + +class Watcher: + """Coalesces filesystem events into incremental rescans.""" + + def __init__(self, cfg: Config, index: Index) -> None: + self.cfg = cfg + self.index = index + self.vault = Path(cfg.vault.path).expanduser() + self._pending: set[str] = set() + self._timer: threading.Timer | None = None + self._lock = threading.Lock() + self._started = False + self._observer = ( + PollingObserver(timeout=cfg.vault.poll_interval) + if cfg.vault.poll_interval + else Observer() + ) + + def touch(self, path: Path) -> None: + try: + rel = path.resolve().relative_to(self.vault.resolve()).as_posix() + except ValueError: + return + with self._lock: + self._pending.add(rel) + if self._timer is not None: + self._timer.cancel() + self._timer = threading.Timer(DEBOUNCE_SECONDS, self.flush) + self._timer.daemon = True + self._timer.start() + + def flush(self) -> None: + with self._lock: + pending, self._pending = sorted(self._pending), set() + self._timer = None + if pending: + self.index.rescan(pending) + + def start(self) -> None: + self._observer.schedule(_Handler(self), str(self.vault), recursive=True) + self._observer.start() + self._started = True + + def stop(self) -> None: + """Safe to call whether or not the observer was ever started.""" + with self._lock: + if self._timer is not None: + self._timer.cancel() + self._timer = None + if not self._started: + return + self._started = False + self._observer.stop() + self._observer.join(timeout=5) diff --git a/src/mdcaldav/writer.py b/src/mdcaldav/writer.py new file mode 100644 index 0000000..8f83ec4 --- /dev/null +++ b/src/mdcaldav/writer.py @@ -0,0 +1,274 @@ +"""Surgical, atomic markdown edits (SPEC 7). + +Every operation splices byte ranges into the file's existing bytes. Untouched +regions are copied verbatim, so line endings, trailing whitespace and the final +newline are preserved exactly. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path + +from .config import Config +from .index import Index, file_hash +from .model import Status, Task + + +class ConflictError(Exception): + """The file changed underneath us; the caller should return 412.""" + + +class NotAllowedError(Exception): + """The operation is disabled by configuration.""" + + +def atomic_write(path: Path, data: bytes) -> None: + """Replace `path` with `data`, preserving mode; never leave a partial file.""" + mode = path.stat().st_mode if path.exists() else None + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + if mode is not None: + os.chmod(tmp, mode) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +def splice(data: bytes, edits: list[tuple[int, int, bytes]]) -> bytes: + """Apply (start, end, replacement) edits; later offsets first so earlier stay valid.""" + for start, end, replacement in sorted(edits, key=lambda e: e[0], reverse=True): + data = data[:start] + replacement + data[end:] + return data + + +class Writer: + def __init__(self, cfg: Config, index: Index) -> None: + self.cfg = cfg + self.index = index + self.vault = Path(cfg.vault.path).expanduser() + self._backed_up: set[str] = set() + + # ---- safety ---------------------------------------------------------- + + def _load(self, uid: str) -> tuple[Task, Path, bytes]: + """Fetch a task with spans known-good against the file's current bytes.""" + task = self.index.tasks.get(uid) + if task is None: + raise KeyError(uid) + rel = task.source.rel_path + path = self.vault / rel + + row = self.index.db.execute( + "SELECT hash FROM files WHERE rel_path = ?", (rel,) + ).fetchone() + data = path.read_bytes() + if row is None or row["hash"] != file_hash(data): + # Changed behind our back — reindex before trusting any span. + self.index.rescan([rel]) + task = self.index.tasks.get(uid) + if task is None: + raise ConflictError(f"{uid} no longer exists in {rel}") + data = path.read_bytes() + + expected = task.content_hash + if _line_hash(data, task) != expected: + raise ConflictError(f"{uid} line changed during write") + return task, path, data + + def _backup(self, rel: str, path: Path) -> None: + if not self.cfg.write.backup_dir or rel in self._backed_up: + return + dest = Path(self.cfg.write.backup_dir).expanduser() / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, dest) + self._backed_up.add(rel) + + # ---- operations ------------------------------------------------------ + + def apply(self, uid: str, changes: dict) -> None: + if not changes: + return + task, path, data = self._load(uid) + edits: list[tuple[int, int, bytes]] = [] + + if "status" in changes: + marker = self.cfg.tasks.status_marker(changes["status"], task.raw_marker) + edits.append((*task.source.marker_span, marker.encode())) + + if "title" in changes: + edits.append((*task.source.title_span, changes["title"].encode())) + + if "priority" in changes: + edits.append(self._priority_edit(task, changes["priority"])) + + if "description" in changes: + edits.append(self._description_edit(task, changes["description"], data)) + + self._backup(task.source.rel_path, path) + atomic_write(path, splice(data, [e for e in edits if e is not None])) + self.index.rescan([task.source.rel_path]) + + def _priority_edit(self, task: Task, priority: int | None) -> tuple[int, int, bytes]: + letter = self.cfg.tasks.priority_letter(priority) + title_start = task.source.title_span[0] + if letter is None: + if task.source.prio_span is None: + return (title_start, title_start, b"") + # Remove the token and the gap that follows it. + return (task.source.prio_span[0], title_start, b"") + token = f"[#{letter}]".encode() + if task.source.prio_span is None: + return (title_start, title_start, token + b" ") + return (*task.source.prio_span, token) + + def _description_edit( + self, task: Task, description: str | None, data: bytes + ) -> tuple[int, int, bytes]: + indent = task.description_indent or task.source.indent + " " + bullet = task.source.bullet + rendered = _render_description(description, indent, bullet) + + if task.description_span is not None: + if rendered: + return (*task.description_span, rendered.encode()) + # Removing: also take the newline that preceded the block. + start, end = task.description_span + return (_back_over_newline(data, start), end, b"") + + if not rendered: + return (task.source.line_span[1], task.source.line_span[1], b"") + newline = _newline_of(data, task.source.line_span[1]) + insert = newline + rendered + return (task.source.line_span[1], task.source.line_span[1], insert.encode()) + + def inbox_for(self, collection: str | None) -> str: + """Where a new task goes, so it stays in the list the client used. + + With directory grouping, `daily` gets `daily/inbox.md`; otherwise the + task would land in a different collection and vanish from the client's + view right after it created it. + """ + inbox = self.cfg.write.inbox + if not collection or self.cfg.collections.group_by != "directory": + return inbox + candidate = self.vault / collection + if candidate.is_dir(): + return f"{collection}/{inbox}" + return inbox + + def create(self, fields: dict, collection: str | None = None) -> str: + if not self.cfg.write.allow_create: + raise NotAllowedError("task creation is disabled") + + rel = self.inbox_for(collection) + path = self.vault / rel + heading = self.cfg.write.inbox_heading + marker = self.cfg.tasks.status_marker( + fields.get("status", Status.NEEDS_ACTION), None + ) + letter = self.cfg.tasks.priority_letter(fields.get("priority")) + prefix = f"[#{letter}] " if letter else "" + line = f"- [{marker}] {prefix}{fields.get('title', 'Untitled')}" + + if path.exists(): + text = path.read_text(encoding="utf-8") + if heading in text: + head, _, tail = text.partition(heading) + body = f"{head}{heading}\n{line}\n{tail.lstrip(chr(10))}" + else: + sep = "" if text.endswith("\n") else "\n" + body = f"{text}{sep}\n{heading}\n\n{line}\n" + else: + path.parent.mkdir(parents=True, exist_ok=True) + body = f"{heading}\n\n{line}\n" + + if path.exists(): + self._backup(rel, path) + atomic_write(path, body.encode()) + else: + path.write_bytes(body.encode()) + + self.index.rescan([rel]) + title = fields.get("title", "Untitled") + for task in self.index.tasks.values(): + if task.source.rel_path == rel and task.title == title: + uid = task.uid + # Adopt the client's UID so the item stays at the href it PUT to. + if wanted := fields.get("uid"): + self.index.reassign_uid(uid, wanted) + uid = wanted + if desc := fields.get("description"): + self.apply(uid, {"description": desc}) + return uid + raise RuntimeError("created task did not survive reindex") + + def delete(self, uid: str) -> None: + if not self.cfg.write.allow_delete: + raise NotAllowedError("task deletion is disabled") + task, path, data = self._load(uid) + + policy = self.cfg.write.delete_children + if task.children and policy == "reject": + raise NotAllowedError("task has subtasks and delete_children=reject") + + start = _back_over_newline(data, task.source.line_span[0]) + end = self._subtree_end(task) if policy == "cascade" else self._own_end(task) + self._backup(task.source.rel_path, path) + atomic_write(path, splice(data, [(start, end, b"")])) + self.index.rescan([task.source.rel_path]) + + def _own_end(self, task: Task) -> int: + end = task.source.line_span[1] + if task.description_span: + end = max(end, task.description_span[1]) + return end + + def _subtree_end(self, task: Task) -> int: + end = self._own_end(task) + for child_uid in task.children: + child = self.index.tasks.get(child_uid) + if child is not None: + end = max(end, self._subtree_end(child)) + return end + + +def _line_hash(data: bytes, task: Task) -> str: + """Recompute the task's identity from the bytes actually on disk.""" + from .model import content_hash + + start, end = task.source.title_span + title = data[start:end].decode("utf-8", "replace") + ms, me = task.source.marker_span + marker = data[ms:me].decode("utf-8", "replace") + return content_hash(title, marker, task.priority) + + +def _newline_of(data: bytes, at: int) -> str: + return "\r\n" if data[at : at + 2] == b"\r\n" else "\n" + + +def _back_over_newline(data: bytes, start: int) -> int: + """Move `start` back over the line ending that precedes it, if any.""" + if start >= 2 and data[start - 2 : start] == b"\r\n": + return start - 2 + if start >= 1 and data[start - 1 : start] == b"\n": + return start - 1 + return start + + +def _render_description(description: str | None, indent: str, bullet: str) -> str: + if not description: + return "" + lines = [] + for raw in description.split("\n"): + extra = len(raw) - len(raw.lstrip(" ")) + lines.append(f"{indent}{' ' * extra}{bullet} {raw.strip()}") + return "\n".join(lines) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..73db158 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from mdcaldav.config import Config +from mdcaldav.parser import parse_file + +FIXTURE_VAULT = Path(__file__).parent / "fixtures" / "vault" + + +@pytest.fixture +def cfg() -> Config: + return Config() + + +@pytest.fixture +def vault(tmp_path: Path) -> Path: + """A writable copy of the real note fixtures.""" + dest = tmp_path / "vault" + shutil.copytree(FIXTURE_VAULT, dest) + return dest + + +@pytest.fixture +def tasks_of(cfg: Config): + def _load(vault: Path, rel: str): + return parse_file(vault / rel, rel, cfg) + + return _load diff --git a/tests/fixtures/vault/daily/2026-01-05.md b/tests/fixtures/vault/daily/2026-01-05.md new file mode 100644 index 0000000..e90e33e --- /dev/null +++ b/tests/fixtures/vault/daily/2026-01-05.md @@ -0,0 +1,33 @@ +--- +date: 2026-01-05 +tags: [daily, garden] +--- +# Monday, 5 January 2026 + +## TODO + +- [ ] Plan the seed order + - [x] Compare catalog prices + - [x] Baker Creek + - [x] Territorial + - [x] Draft the bed layout + - [x] Measure the north plot + - [ ] Decide on trellis height + - [ ] Check the fence bylaw + - [ ] Email the council office + - [x] Sketch it in the notebook + - [x] Order the cover crop + - Arrived on the 12th, invoice is in the binder + - [x] Sharpen the pruners + +- [x] Refill the bird feeder +- [ ] Water the seedlings + +## Log + +Frost warning posted for the weekend. + +### Retrospective + +- Start the tomatoes earlier next year +- Buy fewer squash varieties diff --git a/tests/fixtures/vault/daily/2026-01-06.md b/tests/fixtures/vault/daily/2026-01-06.md new file mode 100644 index 0000000..0cf0302 --- /dev/null +++ b/tests/fixtures/vault/daily/2026-01-06.md @@ -0,0 +1,24 @@ +--- +date: 2026-01-06 +tags: [daily, garden] +--- +# Tuesday, 6 January 2026 + +## TODO + +- [x] Rotate the compost +- [ ] Water the seedlings +- set up the cold frame + - [x] Cut the polycarbonate + - [ ] Hinge the lid + +## Greenhouse repairs + +- [x] Replace the cracked pane + - Used the offcut from the shed + - Silicone needs a day to cure +- [ ] Reseal the base + +## Notes + +The gutter needs clearing before spring. diff --git a/tests/fixtures/vault/daily/2026-01-07.md b/tests/fixtures/vault/daily/2026-01-07.md new file mode 100644 index 0000000..a637496 --- /dev/null +++ b/tests/fixtures/vault/daily/2026-01-07.md @@ -0,0 +1,12 @@ +--- +date: 2026-01-07 +tags: [daily, garden] +--- +# Wednesday, 7 January 2026 + +## TODO + +- [ ] [#A] Book the soil test +- [ ] [#B] Call the arborist +- [ ] [#C] Tidy the potting bench +- [ ] Water the seedlings diff --git a/tests/fixtures/vault/projects/irrigation.md b/tests/fixtures/vault/projects/irrigation.md new file mode 100644 index 0000000..d9ee244 --- /dev/null +++ b/tests/fixtures/vault/projects/irrigation.md @@ -0,0 +1,18 @@ +# Irrigation rebuild + +## Phase one + +- [ ] Trench the main line + - [ ] Rent the trencher + - Depth has to clear the frost line +- [x] Price the manifold + +## Reference + +Nothing actionable in this section. + +```sh +- [ ] this line is a code sample, not a task +``` + + - [ ] indented code block, also not a task diff --git a/tests/fixtures/vault/reference/glossary.md b/tests/fixtures/vault/reference/glossary.md new file mode 100644 index 0000000..d216633 --- /dev/null +++ b/tests/fixtures/vault/reference/glossary.md @@ -0,0 +1,6 @@ +# Glossary + +A file with no tasks at all, to prove empty files are handled. + +- Cold frame: an unheated box with a transparent lid +- Cover crop: a planting grown to protect soil rather than to harvest diff --git a/tests/test_caldav_integration.py b/tests/test_caldav_integration.py new file mode 100644 index 0000000..bf9f18f --- /dev/null +++ b/tests/test_caldav_integration.py @@ -0,0 +1,218 @@ +"""End-to-end: a real CalDAV client against Radicale + this storage plugin. + +SPEC 12 test 10. Exercises the actual HTTP surface (PROPFIND, REPORT, PUT, +DELETE) rather than calling the storage API directly. +""" + +from __future__ import annotations + +import threading +from pathlib import Path +from wsgiref.simple_server import WSGIRequestHandler, make_server + +import pytest + +import mdcaldav.storage as storage_module +from mdcaldav.config import Config + +caldav = pytest.importorskip("caldav") +radicale = pytest.importorskip("radicale") + + +class _QuietHandler(WSGIRequestHandler): + def log_message(self, *args): # keep test output readable + pass + + +@pytest.fixture +def server(vault: Path, tmp_path: Path): + from radicale import Application + from radicale import config as radicale_config + + cfg = Config() + cfg.vault.path = vault + cfg.index.db = tmp_path / "index.db" + storage_module.set_config(cfg) + + configuration = radicale_config.load() + configuration.update( + { + "storage": {"type": "mdcaldav.storage"}, + "auth": {"type": "none"}, + "logging": {"level": "critical"}, + }, + "test", + ) + + httpd = make_server("localhost", 0, Application(configuration), handler_class=_QuietHandler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + url = f"http://localhost:{httpd.server_port}" + try: + yield url, vault + finally: + httpd.shutdown() + thread.join(timeout=5) + storage_module.set_config(Config()) + + +def _calendars(url: str): + client = caldav.DAVClient(url=url, username="test", password="test") + return client.principal().calendars() + + +def test_root_propfind_reports_current_user_principal(server): + """Regression: the root collection's path must be "" or Radicale drops it. + + A root item whose path was the storage's own user name got filtered out of + the multistatus, so clients could not discover the principal at all. Only + surfaced under htpasswd auth, hence this direct check. + """ + import base64 + import urllib.request + + url, _ = server + body = ( + b'<?xml version="1.0"?><propfind xmlns="DAV:">' + b"<prop><current-user-principal/></prop></propfind>" + ) + # Radicale answers 401 for current-user-principal when unauthenticated. + token = base64.b64encode(b"tyler:x").decode() + request = urllib.request.Request( + url + "/", + data=body, + method="PROPFIND", + headers={"Depth": "0", "Authorization": f"Basic {token}"}, + ) + payload = urllib.request.urlopen(request, timeout=10).read().decode() + + assert "current-user-principal" in payload + assert "<href>/</href>" in payload + + +def test_collections_are_discoverable(server): + url, _ = server + names = {cal.url.path.rstrip("/").rsplit("/", 1)[-1] for cal in _calendars(url)} + assert {"daily", "projects"} <= names + + +def test_todos_are_listed_with_fields(server): + url, _ = server + daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily")) + todos = daily.todos(include_completed=True) + assert todos + + summaries = {str(t.icalendar_component.get("SUMMARY")) for t in todos} + assert "Water the seedlings" in summaries + assert "Book the soil test" in summaries + + soil = next( + t + for t in todos + if str(t.icalendar_component.get("SUMMARY")) == "Book the soil test" + ) + assert int(soil.icalendar_component.get("PRIORITY")) == 1 + + +def test_subtasks_carry_related_to(server): + url, _ = server + projects = next( + c for c in _calendars(url) if c.url.path.rstrip("/").endswith("projects") + ) + todos = projects.todos(include_completed=True) + by_summary = {str(t.icalendar_component.get("SUMMARY")): t for t in todos} + + parent = by_summary["Trench the main line"] + child = by_summary["Rent the trencher"] + related = child.icalendar_component.get("RELATED-TO") + assert str(related) == str(parent.icalendar_component.get("UID")) + assert related.params.get("RELTYPE") == "PARENT" + + +def test_completing_a_todo_rewrites_the_markdown(server): + url, vault = server + daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily")) + todo = next( + t + for t in daily.todos() + if str(t.icalendar_component.get("SUMMARY")) == "Book the soil test" + ) + + todo.complete() + + text = (vault / "daily/2026-01-07.md").read_text() + assert "- [x] [#A] Book the soil test" in text + assert "- [ ] [#B] Call the arborist" in text # neighbour untouched + + +def test_description_edit_round_trips_to_markdown(server): + url, vault = server + daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily")) + todo = next( + t + for t in daily.todos() + if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist" + ) + + todo.icalendar_component["DESCRIPTION"] = "Ask about the oak" + todo.save() + + text = (vault / "daily/2026-01-07.md").read_text() + assert "- [ ] [#B] Call the arborist\n - Ask about the oak\n" in text + + +def test_creating_a_todo_lands_in_the_collections_inbox(server): + """A task created in `daily` must stay in `daily`, not jump to another list.""" + url, vault = server + daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily")) + daily.save_todo(summary="Buy netting", priority=1) + + assert "- [ ] [#A] Buy netting" in (vault / "daily/inbox.md").read_text() + assert not (vault / "inbox.md").exists() + + summaries = { + str(t.icalendar_component.get("SUMMARY")) + for t in daily.todos(include_completed=True) + } + assert "Buy netting" in summaries # visible in the list it was created in + + +def test_created_todo_keeps_the_client_uid(server): + url, _ = server + daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily")) + created = daily.save_todo(summary="Mulch the beds") + uid = str(created.icalendar_component.get("UID")) + + fetched = daily.todo_by_uid(uid) + assert str(fetched.icalendar_component.get("SUMMARY")) == "Mulch the beds" + + +def test_deleting_a_todo_removes_the_line(server): + url, vault = server + daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily")) + todo = next( + t + for t in daily.todos() + if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist" + ) + + todo.delete() + + text = (vault / "daily/2026-01-07.md").read_text() + assert "Call the arborist" not in text + assert "Book the soil test" in text + + +def test_external_vim_edit_becomes_visible(server): + """A change made on disk shows up on the next client read.""" + url, vault = server + daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily")) + + rel = vault / "daily/2026-01-07.md" + rel.write_text(rel.read_text() + "- [ ] Order more compost\n") + + summaries = { + str(t.icalendar_component.get("SUMMARY")) + for t in daily.todos(include_completed=True) + } + assert "Order more compost" in summaries diff --git a/tests/test_globs.py b/tests/test_globs.py new file mode 100644 index 0000000..5a71433 --- /dev/null +++ b/tests/test_globs.py @@ -0,0 +1,103 @@ +"""Path filtering, including Syncthing-shaped vaults (SPEC 2.6).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mdcaldav.config import Config +from mdcaldav.globs import matches +from mdcaldav.index import Index + +CONFLICT = "daily/2026-01-05.sync-conflict-20260731-120000-ABCDEFG.md" + + +@pytest.mark.parametrize( + "path,pattern,expected", + [ + # `**/` must mean "zero or more segments", so top-level files match. + ("README.md", "**/*.md", True), + ("daily/a.md", "**/*.md", True), + ("a/b/c/d.md", "**/*.md", True), + # `*` must not cross a separator. + ("a/b.tmp", "*.tmp", False), + ("b.tmp", "*.tmp", True), + # Syncthing artefacts. + (".stversions/daily/2026-01-05.md", ".stversions/**", True), + (CONFLICT, "**/*.sync-conflict-*", True), + ("daily/.syncthing.a.md.tmp", "**/.syncthing.*", True), + ("daily/~syncthing~a.md.tmp", "**/~syncthing~*", True), + # Ordinary notes must survive all of the above. + ("daily/2026-01-05.md", "**/*.sync-conflict-*", False), + ("daily/2026-01-05.md", ".stversions/**", False), + ], +) +def test_glob_semantics(path: str, pattern: str, expected: bool): + assert matches(path, pattern) is expected + + +@pytest.fixture +def syncthing_vault(vault: Path) -> Path: + """A vault shaped like a live Syncthing folder.""" + (vault / ".stfolder").mkdir() + (vault / ".stversions/daily").mkdir(parents=True) + # An old copy of a real note, exactly as Syncthing retains it. + (vault / ".stversions/daily/2026-01-07.md").write_text( + (vault / "daily/2026-01-07.md").read_text(), encoding="utf-8" + ) + (vault / CONFLICT).write_text("- [ ] Book the soil test\n", encoding="utf-8") + (vault / "daily/.syncthing.2026-01-07.md.tmp").write_text("- [ ] partial\n") + (vault / "ROADMAP.md").write_text("- [ ] Top level task\n", encoding="utf-8") + return vault + + +@pytest.fixture +def index(syncthing_vault: Path, tmp_path: Path): + cfg = Config() + cfg.vault.path = syncthing_vault + cfg.index.db = tmp_path / "index.db" + idx = Index(cfg) + idx.rescan() + yield idx + idx.close() + + +def test_top_level_notes_are_indexed(index: Index): + """Regression: `**/*.md` used to skip files at the vault root.""" + assert "Top level task" in {t.title for t in index.tasks.values()} + + +def test_syncthing_artefacts_are_not_indexed(index: Index): + sources = {t.source.rel_path for t in index.tasks.values()} + assert not any(s.startswith(".stversions/") for s in sources) + assert not any(s.startswith(".stfolder/") for s in sources) + assert CONFLICT not in sources + assert not any(".syncthing." in s for s in sources) + + +def test_versioned_copies_do_not_duplicate_tasks(index: Index): + """The failure mode that matters: one task per note, not one per version.""" + titles = [t.title for t in index.tasks.values()] + assert titles.count("Book the soil test") == 1 + + +def test_targeted_rescan_also_filters(index: Index): + """The watcher path must filter too, or Syncthing writes poison the index.""" + index.rescan([".stversions/daily/2026-01-07.md", CONFLICT]) + + titles = [t.title for t in index.tasks.values()] + assert titles.count("Book the soil test") == 1 + sources = {t.source.rel_path for t in index.tasks.values()} + assert not any(s.startswith(".stversions/") for s in sources) + + +def test_doctor_reports_conflict_files(syncthing_vault: Path, tmp_path, capsys, monkeypatch): + from mdcaldav.cli import main + + monkeypatch.setenv("HOME", str(tmp_path)) + main(["--vault", str(syncthing_vault), "doctor"]) + + out = capsys.readouterr().out + assert "sync-conflict files present" in out + assert "merge and delete them" in out diff --git a/tests/test_ical.py b/tests/test_ical.py new file mode 100644 index 0000000..25b9652 --- /dev/null +++ b/tests/test_ical.py @@ -0,0 +1,128 @@ +"""iCalendar mapping tests (SPEC 4).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from mdcaldav.config import Config +from mdcaldav.ical import apply_to_task, escape, fold, from_ics, to_ics, unescape +from mdcaldav.model import Status +from mdcaldav.parser import parse + + +def prop(text: str, name: str) -> str | None: + from mdcaldav.ical import unfold + + for line in unfold(text): + key = line.split(":", 1)[0].split(";", 1)[0] + if key == name: + return line.split(":", 1)[1] + return None + + +def one(src: bytes, cfg: Config): + return parse(src, "t.md", cfg)[0] + + +def test_basic_vtodo(cfg: Config): + text = to_ics(one(b"- [ ] Water the seedlings\n", cfg)) + assert "BEGIN:VTODO" in text and "END:VTODO" in text + assert prop(text, "SUMMARY") == "Water the seedlings" + assert prop(text, "STATUS") == "NEEDS-ACTION" + assert prop(text, "PRIORITY") is None + assert text.endswith("\r\n") + + +def test_completed_maps_percent_and_timestamp(cfg: Config): + when = datetime(2026, 1, 7, 15, 30, tzinfo=timezone.utc) + text = to_ics(one(b"- [x] Rotate the compost\n", cfg), completed_at=when) + assert prop(text, "STATUS") == "COMPLETED" + assert prop(text, "PERCENT-COMPLETE") == "100" + assert prop(text, "COMPLETED") == "20260107T153000Z" + + +def test_priority_bands(cfg: Config): + for marker, expected in (("A", "1"), ("B", "5"), ("C", "9")): + text = to_ics(one(f"- [ ] [#{marker}] x\n".encode(), cfg)) + assert prop(text, "PRIORITY") == expected + + +def test_description_and_categories(cfg: Config): + src = b"## Garden\n\n- [ ] Prune\n - Use the long loppers\n" + task = parse(src, "t.md", cfg)[0] + text = to_ics(task) + assert prop(text, "DESCRIPTION") == "Use the long loppers" + assert "Garden" in (prop(text, "CATEGORIES") or "") + + +def test_parent_relation(cfg: Config): + tasks = parse(b"- [ ] parent\n - [ ] child\n", "t.md", cfg) + text = to_ics(tasks[1]) + assert f"RELATED-TO;RELTYPE=PARENT:{tasks[0].uid}" in text + + +def test_text_escaping_round_trip(): + raw = "Comma, semi; back\\slash\nnewline" + assert unescape(escape(raw)) == raw + + +def test_special_characters_survive_serialization(cfg: Config): + src = "- [ ] Buy: milk, eggs; and a note\\here\n".encode() + text = to_ics(one(src, cfg)) + assert from_ics(text)["title"] == "Buy: milk, eggs; and a note\\here" + + +def test_folding_respects_character_boundaries(): + line = "SUMMARY:" + "é" * 100 + folded = fold(line) + assert all(len(seg.encode()) <= 75 for seg in folded.split("\r\n ")) + from mdcaldav.ical import unfold + + assert unfold(folded)[0] == line + + +def test_long_summary_round_trips(cfg: Config): + title = "Plan " + "the very long garden bed layout " * 6 + text = to_ics(one(f"- [ ] {title}\n".encode(), cfg)) + assert from_ics(text)["title"] == title.strip() + + +def test_from_ics_ignores_unmodelled_properties(): + text = ( + "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:abc\r\nSUMMARY:Task\r\n" + "RRULE:FREQ=WEEKLY\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\n" + "SUMMARY:Alarm text\r\nEND:VALARM\r\nSTATUS:COMPLETED\r\n" + "END:VTODO\r\nEND:VCALENDAR\r\n" + ) + fields = from_ics(text) + assert fields["title"] == "Task" # not clobbered by the VALARM's SUMMARY + assert fields["status"] is Status.COMPLETED + assert "rrule" not in fields + + +def test_percent_complete_implies_completion(): + text = ( + "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:abc\r\nSUMMARY:T\r\n" + "PERCENT-COMPLETE:100\r\nEND:VTODO\r\nEND:VCALENDAR\r\n" + ) + assert from_ics(text)["status"] is Status.COMPLETED + + +def test_apply_to_task_reports_only_real_changes(cfg: Config): + task = one(b"- [ ] [#A] Ship it\n", cfg) + same = from_ics(to_ics(task)) + assert apply_to_task(task, same, cfg) == {} + + changed = dict(same, status=Status.COMPLETED, title="Ship it now") + assert apply_to_task(task, changed, cfg) == { + "status": Status.COMPLETED, + "title": "Ship it now", + } + + +def test_priority_snaps_to_configured_bands(cfg: Config): + task = one(b"- [ ] [#A] Ship it\n", cfg) + # A client sending 3 stays in the "high" band → still [#A], so no change. + assert apply_to_task(task, {"priority": 3}, cfg) == {} + assert apply_to_task(task, {"priority": 9}, cfg) == {"priority": 9} + assert apply_to_task(task, {"priority": 0}, cfg) == {"priority": None} diff --git a/tests/test_index.py b/tests/test_index.py new file mode 100644 index 0000000..1275fe9 --- /dev/null +++ b/tests/test_index.py @@ -0,0 +1,190 @@ +"""Identity, generation and sync-token tests (SPEC 12, tests 4-7/11).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mdcaldav.config import Config +from mdcaldav.index import Index +from mdcaldav.model import Status + + +@pytest.fixture +def index(vault: Path, tmp_path: Path) -> Index: + cfg = Config() + cfg.vault.path = vault + cfg.index.db = tmp_path / "index.db" + idx = Index(cfg) + idx.rescan() + yield idx + idx.close() + + +def uid_of(index: Index, title: str, rel: str | None = None) -> str: + matches = [ + t + for t in index.tasks.values() + if t.title == title and (rel is None or t.source.rel_path == rel) + ] + assert len(matches) == 1, f"{title!r} matched {len(matches)} tasks" + return matches[0].uid + + +def write(vault: Path, rel: str, text: str) -> None: + (vault / rel).write_text(text, encoding="utf-8") + + +def test_uids_are_stable_across_an_unchanged_rescan(index: Index): + before = {t.uid: t.title for t in index.tasks.values()} + index.rescan() + after = {t.uid: t.title for t in index.tasks.values()} + assert before == after + + +def test_status_change_preserves_uid(index: Index, vault: Path): + uid = uid_of(index, "Water the seedlings", "daily/2026-01-07.md") + rel = "daily/2026-01-07.md" + text = (vault / rel).read_text() + write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) + index.rescan() + + assert index.tasks[uid].status is Status.COMPLETED + assert uid_of(index, "Water the seedlings", rel) == uid + + +def test_reorder_preserves_uid(index: Index, vault: Path): + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Call the arborist", rel) + text = (vault / rel).read_text() + lines = text.splitlines(keepends=True) + lines[8], lines[9] = lines[9], lines[8] # swap two task lines + write(vault, rel, "".join(lines)) + index.rescan() + + assert uid_of(index, "Call the arborist", rel) == uid + + +def test_retitle_in_place_preserves_uid(index: Index, vault: Path): + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Tidy the potting bench", rel) + text = (vault / rel).read_text() + write(vault, rel, text.replace("Tidy the potting bench", "Tidy the potting bench properly")) + index.rescan() + + assert uid_of(index, "Tidy the potting bench properly", rel) == uid + + +def test_carry_forward_duplicates_stay_distinct(index: Index): + """SPEC 6.3: the same title in three daily notes is three tasks, not one.""" + uids = { + rel: uid_of(index, "Water the seedlings", rel) + for rel in ( + "daily/2026-01-05.md", + "daily/2026-01-06.md", + "daily/2026-01-07.md", + ) + } + assert len(set(uids.values())) == 3 + + +def test_completing_one_carry_forward_copy_does_not_affect_others( + index: Index, vault: Path +): + monday = uid_of(index, "Water the seedlings", "daily/2026-01-05.md") + wednesday = uid_of(index, "Water the seedlings", "daily/2026-01-07.md") + + rel = "daily/2026-01-07.md" + text = (vault / rel).read_text() + write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) + index.rescan() + + assert index.tasks[wednesday].status is Status.COMPLETED + assert index.tasks[monday].status is Status.NEEDS_ACTION + + +def test_true_cross_file_move_preserves_uid(index: Index, vault: Path): + """A task that disappears from A and appears in B keeps its UID.""" + uid = uid_of(index, "Book the soil test", "daily/2026-01-07.md") + + src = "daily/2026-01-07.md" + text = (vault / src).read_text() + write(vault, src, text.replace("- [ ] [#A] Book the soil test\n", "")) + dest = "daily/2026-01-06.md" + write(vault, dest, (vault / dest).read_text() + "\n- [ ] [#A] Book the soil test\n") + index.rescan() + + assert uid_of(index, "Book the soil test", dest) == uid + + +def test_deleted_task_is_tombstoned(index: Index, vault: Path): + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Call the arborist", rel) + text = (vault / rel).read_text() + write(vault, rel, text.replace("- [ ] [#B] Call the arborist\n", "")) + index.rescan() + + assert uid not in index.tasks + row = index.db.execute( + "SELECT deleted_gen FROM tasks WHERE uid = ?", (uid,) + ).fetchone() + assert row["deleted_gen"] is not None + + +def test_uids_survive_a_restart(vault: Path, tmp_path: Path): + cfg = Config() + cfg.vault.path = vault + cfg.index.db = tmp_path / "index.db" + + first = Index(cfg) + first.rescan() + before = {t.uid for t in first.tasks.values()} + first.close() + + second = Index(cfg) + second.rescan() + assert {t.uid for t in second.tasks.values()} == before + second.close() + + +def test_completed_timestamp_recorded_on_transition(index: Index, vault: Path): + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Water the seedlings", rel) + assert index.completed_at(uid) is None + + text = (vault / rel).read_text() + write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) + index.rescan() + assert index.completed_at(uid) is not None + + +def test_collections_follow_directories(index: Index): + assert set(index.collections()) == {"daily", "projects"} + + +def test_sync_returns_only_changed(index: Index, vault: Path): + token, hrefs = index.sync("daily") + assert hrefs # initial sync returns everything + + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Water the seedlings", rel) + text = (vault / rel).read_text() + write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) + index.rescan() + + _, changed = index.sync("daily", token) + assert changed == [f"{uid}.ics"] + + +def test_sync_rejects_stale_token(index: Index): + index.cfg.index.tombstone_retention = 1 + for _ in range(4): + index.rescan() + with pytest.raises(ValueError): + index.sync("daily", "http://mdcaldav.local/ns/sync/0") + + +def test_sync_rejects_malformed_token(index: Index): + with pytest.raises(ValueError): + index.sync("daily", "not-a-token") diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..8193024 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,193 @@ +"""Parser tests against synthetic note fixtures (SPEC 12, tests 1/2/9).""" + +from __future__ import annotations + +from mdcaldav.config import Config +from mdcaldav.model import Status +from mdcaldav.parser import parse + + +def titles(tasks): + return [t.title for t in tasks] + + +def by_title(tasks, title): + return next(t for t in tasks if t.title == title) + + +def test_flat_daily_note(vault, tasks_of): + tasks = tasks_of(vault, "daily/2026-01-07.md") + assert titles(tasks) == [ + "Book the soil test", + "Call the arborist", + "Tidy the potting bench", + "Water the seedlings", + ] + assert [t.priority for t in tasks] == [1, 5, 9, None] + assert all(t.status is Status.NEEDS_ACTION for t in tasks) + + +def test_nesting_and_parentage(vault, tasks_of): + tasks = tasks_of(vault, "daily/2026-01-06.md") + parent = by_title(tasks, "Replace the cracked pane") + assert parent.parent_uid is None + assert parent.depth == 0 + + tasks = tasks_of(vault, "projects/irrigation.md") + trench = by_title(tasks, "Trench the main line") + rent = by_title(tasks, "Rent the trencher") + assert rent.parent_uid == trench.uid + assert rent.uid in trench.children + assert (trench.depth, rent.depth) == (0, 1) + + +def test_deep_nesting_five_levels(vault, tasks_of): + tasks = tasks_of(vault, "daily/2026-01-05.md") + deep = by_title(tasks, "Email the council office") + assert deep.depth == 4 + + lookup = {t.uid: t for t in tasks} + chain, node = [], deep + while node.parent_uid: + node = lookup[node.parent_uid] + chain.append(node.title) + assert chain == [ + "Check the fence bylaw", + "Decide on trellis height", + "Draft the bed layout", + "Plan the seed order", + ] + + +def test_sub_bullet_becomes_description(vault, tasks_of): + """SPEC 2.3: non-checkbox bullet with no checkbox descendants.""" + tasks = tasks_of(vault, "daily/2026-01-05.md") + assert by_title(tasks, "Order the cover crop").description == ( + "Arrived on the 12th, invoice is in the binder" + ) + assert by_title(tasks, "Sharpen the pruners").description is None + + +def test_multiple_description_bullets_join(vault, tasks_of): + tasks = tasks_of(vault, "daily/2026-01-06.md") + assert by_title(tasks, "Replace the cracked pane").description == ( + "Used the offcut from the shed\nSilicone needs a day to cure" + ) + + +def test_group_node_is_not_a_task(vault, tasks_of): + """SPEC 2.3: a plain bullet parenting checkboxes contributes context only.""" + tasks = tasks_of(vault, "daily/2026-01-06.md") + label = "set up the cold frame" + assert label not in titles(tasks) + + cut = by_title(tasks, "Cut the polycarbonate") + assert cut.group_path == (label,) + assert label in cut.categories + assert cut.parent_uid is None # re-parented to nearest task ancestor: none + assert cut.depth == 0 + + +def test_note_bullets_produce_nothing(vault, tasks_of): + """SPEC 2.3: plain bullets with no task ancestor are dropped entirely.""" + tasks = tasks_of(vault, "daily/2026-01-05.md") + assert "Buy fewer squash varieties" not in titles(tasks) + assert not any( + t.description and "squash" in t.description for t in tasks + ) + + +def test_tasks_collected_under_any_heading(vault, tasks_of): + tasks = tasks_of(vault, "daily/2026-01-06.md") + # heading_path is the full stack, so the H1 note title leads. + assert by_title(tasks, "Reseal the base").heading_path == ( + "Tuesday, 6 January 2026", + "Greenhouse repairs", + ) + + +def test_file_with_no_tasks(vault, tasks_of): + assert tasks_of(vault, "reference/glossary.md") == [] + + +def test_code_blocks_are_not_tasks(vault, tasks_of): + """SPEC 2.6: fenced and indented code must not yield tasks.""" + tasks = tasks_of(vault, "projects/irrigation.md") + assert titles(tasks) == [ + "Trench the main line", + "Rent the trencher", + "Price the manifold", + ] + + +def test_tilde_fence(cfg: Config): + src = b"- [ ] real\n\n~~~\n- [ ] fake\n~~~\n" + assert titles(parse(src, "f.md", cfg)) == ["real"] + + +def test_states(cfg: Config): + tasks = parse(b"- [ ] a\n- [x] b\n- [X] c\n- [/] d\n- [-] e\n- [?] f\n", "s.md", cfg) + assert [t.status for t in tasks] == [ + Status.NEEDS_ACTION, + Status.COMPLETED, + Status.COMPLETED, + Status.IN_PROCESS, + Status.CANCELLED, + Status.NEEDS_ACTION, + ] + assert tasks[-1].raw_marker == "?" # unknown marker preserved verbatim + + +def test_frontmatter_is_not_content(cfg: Config): + src = b"---\ndate: 2026-01-05\ntags: [daily]\n---\n\n- [ ] only\n" + assert titles(parse(src, "f.md", cfg)) == ["only"] + + +def test_spans_locate_exact_bytes(cfg: Config): + src = b"- [ ] [#A] Ship it\n" + (task,) = parse(src, "s.md", cfg) + assert src[slice(*task.source.marker_span)] == b" " + assert src[slice(*task.source.title_span)] == b"Ship it" + assert src[slice(*task.source.prio_span)] == b"[#A]" + + +def test_spans_are_byte_offsets_with_unicode(cfg: Config): + """Byte spans must stay correct when the line contains multi-byte characters.""" + src = "- [x] café → naïve\n".encode() + (task,) = parse(src, "u.md", cfg) + assert src[slice(*task.source.marker_span)] == b"x" + assert src[slice(*task.source.title_span)].decode() == "café → naïve" + + +def test_crlf_line_endings(cfg: Config): + (task,) = parse(b"- [ ] windows\r\n", "w.md", cfg) + assert task.title == "windows" + assert task.source.line_span == (0, 13) # excludes the \r\n + + +def test_bullet_variants(cfg: Config): + tasks = parse(b"- [ ] dash\n* [ ] star\n+ [ ] plus\n", "b.md", cfg) + assert titles(tasks) == ["dash", "star", "plus"] + assert [t.source.bullet for t in tasks] == ["-", "*", "+"] + + +def test_tags_become_categories(cfg: Config): + (task,) = parse(b"- [ ] Fix #1923 for #garden\n", "t.md", cfg) + assert "garden" in task.categories + assert "1923" not in task.categories # bare numbers are not tags + assert task.title == "Fix #1923 for #garden" # tags stay in the title + + +def test_sibling_index_disambiguates_duplicates(cfg: Config): + tasks = parse(b"- [ ] same\n- [ ] same\n- [ ] same\n", "d.md", cfg) + assert [t.sibling_index for t in tasks] == [0, 1, 2] + + +def test_due_syntax_opt_in(cfg: Config): + src = b"- [ ] Prune the apple tree @due(2026-03-01)\n" + assert parse(src, "n.md", cfg)[0].due is None # disabled by default + + cfg.tasks.due_syntax = ["@due(%Y-%m-%d)"] + task = parse(src, "n.md", cfg)[0] + assert task.due is not None and task.due.isoformat() == "2026-03-01" + assert task.title == "Prune the apple tree" diff --git a/tests/test_watcher_cli.py b/tests/test_watcher_cli.py new file mode 100644 index 0000000..7e4b9c2 --- /dev/null +++ b/tests/test_watcher_cli.py @@ -0,0 +1,108 @@ +"""Watcher debounce and CLI smoke tests (SPEC 8, 10.1).""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from mdcaldav.cli import main +from mdcaldav.config import Config +from mdcaldav.index import Index +from mdcaldav.watcher import Watcher + + +@pytest.fixture +def index(vault: Path, tmp_path: Path): + cfg = Config() + cfg.vault.path = vault + cfg.index.db = tmp_path / "index.db" + idx = Index(cfg) + idx.rescan() + yield idx + idx.close() + + +def _titles(index: Index) -> set[str]: + return {t.title for t in index.tasks.values()} + + +def test_watcher_picks_up_a_new_task(index: Index, vault: Path): + watcher = Watcher(index.cfg, index) + watcher.start() + try: + rel = vault / "daily/2026-01-07.md" + rel.write_text(rel.read_text() + "- [ ] Order more compost\n") + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if "Order more compost" in _titles(index): + break + time.sleep(0.05) + assert "Order more compost" in _titles(index) + finally: + watcher.stop() + + +def test_watcher_debounces_a_burst(index: Index, vault: Path): + """Many rapid saves should collapse into far fewer rescans.""" + watcher = Watcher(index.cfg, index) + rel = vault / "daily/2026-01-07.md" + base = index.generation + + for n in range(10): + rel.write_text(rel.read_text() + f"- [ ] burst {n}\n") + watcher.touch(rel) + watcher.flush() + + assert index.generation - base == 1 # one rescan, not ten + assert "burst 9" in _titles(index) + watcher.stop() + + +def test_watcher_ignores_paths_outside_the_vault(index: Index, tmp_path: Path): + watcher = Watcher(index.cfg, index) + watcher.touch(tmp_path / "elsewhere.md") + assert watcher._pending == set() + watcher.stop() + + +def test_refresh_if_stale_is_a_noop_when_unchanged(index: Index): + assert index.refresh_if_stale() is False + + +def test_refresh_if_stale_detects_deletion(index: Index, vault: Path): + (vault / "daily/2026-01-07.md").unlink() + assert index.refresh_if_stale() is True + assert "Book the soil test" not in _titles(index) + + +def test_cli_scan(vault: Path, tmp_path: Path, capsys, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + rc = main(["--vault", str(vault), "scan"]) + out = capsys.readouterr().out + + assert rc == 0 + assert "Plan the seed order" in out + assert "Email the council office" in out # nested tasks are shown + assert "[#A] Book the soil test" in out # priority rendered + assert "tasks in" in out + + +def test_cli_doctor(vault: Path, tmp_path: Path, capsys, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + rc = main(["--vault", str(vault), "doctor"]) + out = capsys.readouterr().out + + assert rc == 0 + assert "collections:" in out + assert "daily" in out + + +def test_cli_doctor_flags_ambiguous_titles(vault: Path, tmp_path: Path, capsys, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + (vault / "daily/dupes.md").write_text("- [ ] same thing\n- [ ] same thing\n") + + main(["--vault", str(vault), "doctor"]) + assert "ambiguous titles" in capsys.readouterr().out diff --git a/tests/test_writer.py b/tests/test_writer.py new file mode 100644 index 0000000..292b2e6 --- /dev/null +++ b/tests/test_writer.py @@ -0,0 +1,285 @@ +"""Write-back tests: surgical edits, atomicity, conflicts (SPEC 12, tests 3/8).""" + +from __future__ import annotations + +import difflib +from pathlib import Path + +import pytest + +from mdcaldav.config import Config +from mdcaldav.index import Index +from mdcaldav.model import Status +from mdcaldav.writer import ConflictError, NotAllowedError, Writer + + +@pytest.fixture +def env(vault: Path, tmp_path: Path): + cfg = Config() + cfg.vault.path = vault + cfg.index.db = tmp_path / "index.db" + index = Index(cfg) + index.rescan() + yield cfg, index, Writer(cfg, index), vault + index.close() + + +def uid_of(index: Index, title: str, rel: str | None = None) -> str: + matches = [ + t + for t in index.tasks.values() + if t.title == title and (rel is None or t.source.rel_path == rel) + ] + assert len(matches) == 1, f"{title!r} matched {len(matches)}" + return matches[0].uid + + +def diff_lines(before: str, after: str) -> list[str]: + return [ + line + for line in difflib.unified_diff( + before.splitlines(), after.splitlines(), lineterm="", n=0 + ) + if line[:1] in "+-" and not line.startswith(("---", "+++")) + ] + + +def test_completion_changes_exactly_one_line(env): + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + before = (vault / rel).read_text() + + writer.apply(uid_of(index, "Water the seedlings", rel), {"status": Status.COMPLETED}) + + after = (vault / rel).read_text() + assert diff_lines(before, after) == [ + "-- [ ] Water the seedlings", + "+- [x] Water the seedlings", + ] + + +def test_completion_toggles_back(env): + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + before = (vault / rel).read_bytes() + uid = uid_of(index, "Water the seedlings", rel) + + writer.apply(uid, {"status": Status.COMPLETED}) + writer.apply(uid, {"status": Status.NEEDS_ACTION}) + + assert (vault / rel).read_bytes() == before # round trip is byte-identical + + +def test_nested_task_completion_leaves_siblings_alone(env): + _, index, writer, vault = env + rel = "daily/2026-01-05.md" + before = (vault / rel).read_text() + + writer.apply( + uid_of(index, "Decide on trellis height", rel), {"status": Status.COMPLETED} + ) + + after = (vault / rel).read_text() + assert diff_lines(before, after) == [ + "- - [ ] Decide on trellis height", + "+ - [x] Decide on trellis height", + ] + + +def test_retitle_preserves_indent_bullet_and_priority(env): + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + writer.apply(uid_of(index, "Book the soil test", rel), {"title": "Book the soil test today"}) + assert "- [ ] [#A] Book the soil test today" in (vault / rel).read_text() + + +def test_priority_set_clear_and_change(env): + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + + uid = uid_of(index, "Water the seedlings", rel) + writer.apply(uid, {"priority": 1}) + assert "- [ ] [#A] Water the seedlings" in (vault / rel).read_text() + + writer.apply(uid, {"priority": 9}) + assert "- [ ] [#C] Water the seedlings" in (vault / rel).read_text() + + writer.apply(uid, {"priority": None}) + assert "- [ ] Water the seedlings" in (vault / rel).read_text() + assert "[#" not in (vault / rel).read_text().split("Water the seedlings")[0].splitlines()[-1] + + +def test_description_replaced_in_place(env): + _, index, writer, vault = env + rel = "daily/2026-01-05.md" + writer.apply( + uid_of(index, "Order the cover crop", rel), {"description": "Invoice filed"} + ) + text = (vault / rel).read_text() + assert " - Invoice filed" in text + assert "Arrived on the 12th" not in text + + +def test_description_added_where_none_existed(env): + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + writer.apply( + uid_of(index, "Call the arborist", rel), {"description": "Ask about the oak"} + ) + text = (vault / rel).read_text() + assert "- [ ] [#B] Call the arborist\n - Ask about the oak\n" in text + + +def test_description_removed(env): + _, index, writer, vault = env + rel = "daily/2026-01-05.md" + writer.apply(uid_of(index, "Order the cover crop", rel), {"description": None}) + text = (vault / rel).read_text() + assert "Arrived on the 12th" not in text + assert "- [x] Order the cover crop\n - [x] Sharpen the pruners" in text + + +def test_untouched_files_are_not_rewritten(env): + _, index, writer, vault = env + others = { + rel: (vault / rel).read_bytes() + for rel in ("daily/2026-01-06.md", "projects/irrigation.md", "reference/glossary.md") + } + writer.apply( + uid_of(index, "Water the seedlings", "daily/2026-01-07.md"), + {"status": Status.COMPLETED}, + ) + for rel, content in others.items(): + assert (vault / rel).read_bytes() == content + + +def test_crlf_and_missing_final_newline_preserved(env, tmp_path: Path): + cfg, index, writer, vault = env + rel = "daily/crlf.md" + (vault / rel).write_bytes(b"# T\r\n\r\n- [ ] alpha\r\n- [ ] omega") # no final newline + index.rescan() + + writer.apply(uid_of(index, "alpha", rel), {"status": Status.COMPLETED}) + + data = (vault / rel).read_bytes() + assert data == b"# T\r\n\r\n- [x] alpha\r\n- [ ] omega" + + +def test_unicode_title_edit_keeps_bytes_aligned(env): + _, index, writer, vault = env + rel = "daily/uni.md" + (vault / rel).write_text("- [ ] café → naïve\n- [ ] after\n", encoding="utf-8") + index.rescan() + + writer.apply(uid_of(index, "café → naïve", rel), {"status": Status.COMPLETED}) + assert (vault / rel).read_text() == "- [x] café → naïve\n- [ ] after\n" + + +def test_conflict_when_task_deleted_underneath(env): + """The task the client addressed no longer exists → refuse, don't guess.""" + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Water the seedlings", rel) + + text = (vault / rel).read_text() + (vault / rel).write_text(text.replace("- [ ] Water the seedlings\n", "")) + before = (vault / rel).read_bytes() + + with pytest.raises(ConflictError): + writer.apply(uid, {"status": Status.COMPLETED}) + assert (vault / rel).read_bytes() == before # not corrupted + + +def test_external_retitle_is_the_same_task(env): + """Identity rule 2 treats an in-place retitle as the same task, so the + write lands on the renamed line rather than failing. The client's own + staleness is caught a layer up by Radicale's If-Match/ETag check.""" + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Water the seedlings", rel) + + text = (vault / rel).read_text() + (vault / rel).write_text(text.replace("Water the seedlings", "Water the beds")) + + writer.apply(uid, {"status": Status.COMPLETED}) + assert "- [x] Water the beds" in (vault / rel).read_text() + + +def test_stale_spans_are_never_used(env): + """An edit that shifts line offsets must not corrupt a later write.""" + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Water the seedlings", rel) + + # Insert lines above the target, invalidating every cached byte offset. + text = (vault / rel).read_text() + (vault / rel).write_text(text.replace("## TODO\n", "## TODO\n\nSome new prose here.\n\n")) + + writer.apply(uid, {"status": Status.COMPLETED}) + result = (vault / rel).read_text() + assert "- [x] Water the seedlings" in result + assert "Some new prose here." in result + assert "- [ ] [#A] Book the soil test" in result # neighbours intact + + +def test_unrelated_external_edit_is_absorbed(env): + """A change elsewhere in the file must not block an unrelated write.""" + _, index, writer, vault = env + rel = "daily/2026-01-07.md" + uid = uid_of(index, "Water the seedlings", rel) + + text = (vault / rel).read_text() + (vault / rel).write_text(text.replace("# Wednesday", "# Weds")) + + writer.apply(uid, {"status": Status.COMPLETED}) + assert "- [x] Water the seedlings" in (vault / rel).read_text() + + +def test_delete_cascades_to_subtree(env): + _, index, writer, vault = env + rel = "projects/irrigation.md" + writer.delete(uid_of(index, "Trench the main line", rel)) + text = (vault / rel).read_text() + assert "Trench the main line" not in text + assert "Rent the trencher" not in text # cascaded + assert "frost line" not in text # its description went too + assert "Price the manifold" in text # sibling untouched + + +def test_delete_reject_policy(env): + cfg, index, writer, vault = env + cfg.write.delete_children = "reject" + with pytest.raises(NotAllowedError): + writer.delete(uid_of(index, "Trench the main line", "projects/irrigation.md")) + + +def test_delete_disabled(env): + cfg, index, writer, _ = env + cfg.write.allow_delete = False + with pytest.raises(NotAllowedError): + writer.delete(uid_of(index, "Reseal the base", "daily/2026-01-06.md")) + + +def test_create_lands_in_inbox(env): + _, index, writer, vault = env + uid = writer.create({"title": "Buy netting", "priority": 1}) + text = (vault / "inbox.md").read_text() + assert "## Inbox" in text + assert "- [ ] [#A] Buy netting" in text + assert index.tasks[uid].title == "Buy netting" + + +def test_create_disabled(env): + cfg, _, writer, _ = env + cfg.write.allow_create = False + with pytest.raises(NotAllowedError): + writer.create({"title": "nope"}) + + +def test_atomic_write_leaves_no_temp_files(env): + _, index, writer, vault = env + writer.apply( + uid_of(index, "Water the seedlings", "daily/2026-01-07.md"), + {"status": Status.COMPLETED}, + ) + assert not list((vault / "daily").glob(".*tmp*"))