Initial commit
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
.git/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
data/
|
||||
tests/
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -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
|
||||
93
.gitea/workflows/ci.yml
Normal file
93
.gitea/workflows/ci.yml
Normal file
@@ -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
|
||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -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/
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -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"]
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
214
README.md
Normal file
214
README.md
Normal file
@@ -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 `<collection>/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.
|
||||
715
SPEC.md
Normal file
715
SPEC.md
Normal file
@@ -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
|
||||
|
||||
```
|
||||
<indent><bullet><space>[<state>]<space>(<priority><space>)?<title>(<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.
|
||||
72
docker-compose.prod.yml
Normal file
72
docker-compose.prod.yml
Normal file
@@ -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:
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -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
|
||||
25
docker/mdcaldav.toml
Normal file
25
docker/mdcaldav.toml
Normal file
@@ -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"
|
||||
21
docker/radicale.conf
Normal file
21
docker/radicale.conf
Normal file
@@ -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
|
||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -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"]
|
||||
30
reverse-proxy/Caddyfile.example
Normal file
30
reverse-proxy/Caddyfile.example
Normal file
@@ -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
|
||||
# }
|
||||
# }
|
||||
54
reverse-proxy/nginx.conf.example
Normal file
54
reverse-proxy/nginx.conf.example
Normal file
@@ -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;
|
||||
# }
|
||||
# }
|
||||
0
src/mdcaldav/__init__.py
Normal file
0
src/mdcaldav/__init__.py
Normal file
173
src/mdcaldav/cli.py
Normal file
173
src/mdcaldav/cli.py
Normal file
@@ -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())
|
||||
57
src/mdcaldav/collections.py
Normal file
57
src/mdcaldav/collections.py
Normal file
@@ -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)
|
||||
165
src/mdcaldav/config.py
Normal file
165
src/mdcaldav/config.py
Normal file
@@ -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
|
||||
57
src/mdcaldav/globs.py
Normal file
57
src/mdcaldav/globs.py
Normal file
@@ -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)
|
||||
199
src/mdcaldav/ical.py
Normal file
199
src/mdcaldav/ical.py
Normal file
@@ -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
|
||||
431
src/mdcaldav/index.py
Normal file
431
src/mdcaldav/index.py
Normal file
@@ -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()
|
||||
74
src/mdcaldav/model.py
Normal file
74
src/mdcaldav/model.py
Normal file
@@ -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())
|
||||
286
src/mdcaldav/parser.py
Normal file
286
src/mdcaldav/parser.py
Normal file
@@ -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)
|
||||
286
src/mdcaldav/storage.py
Normal file
286
src/mdcaldav/storage.py
Normal file
@@ -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}", []
|
||||
82
src/mdcaldav/watcher.py
Normal file
82
src/mdcaldav/watcher.py
Normal file
@@ -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)
|
||||
274
src/mdcaldav/writer.py
Normal file
274
src/mdcaldav/writer.py
Normal file
@@ -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)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
32
tests/conftest.py
Normal file
32
tests/conftest.py
Normal file
@@ -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
|
||||
33
tests/fixtures/vault/daily/2026-01-05.md
vendored
Normal file
33
tests/fixtures/vault/daily/2026-01-05.md
vendored
Normal file
@@ -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
|
||||
24
tests/fixtures/vault/daily/2026-01-06.md
vendored
Normal file
24
tests/fixtures/vault/daily/2026-01-06.md
vendored
Normal file
@@ -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.
|
||||
12
tests/fixtures/vault/daily/2026-01-07.md
vendored
Normal file
12
tests/fixtures/vault/daily/2026-01-07.md
vendored
Normal file
@@ -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
|
||||
18
tests/fixtures/vault/projects/irrigation.md
vendored
Normal file
18
tests/fixtures/vault/projects/irrigation.md
vendored
Normal file
@@ -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
|
||||
6
tests/fixtures/vault/reference/glossary.md
vendored
Normal file
6
tests/fixtures/vault/reference/glossary.md
vendored
Normal file
@@ -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
|
||||
218
tests/test_caldav_integration.py
Normal file
218
tests/test_caldav_integration.py
Normal file
@@ -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
|
||||
103
tests/test_globs.py
Normal file
103
tests/test_globs.py
Normal file
@@ -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
|
||||
128
tests/test_ical.py
Normal file
128
tests/test_ical.py
Normal file
@@ -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}
|
||||
190
tests/test_index.py
Normal file
190
tests/test_index.py
Normal file
@@ -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")
|
||||
193
tests/test_parser.py
Normal file
193
tests/test_parser.py
Normal file
@@ -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"
|
||||
108
tests/test_watcher_cli.py
Normal file
108
tests/test_watcher_cli.py
Normal file
@@ -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
|
||||
285
tests/test_writer.py
Normal file
285
tests/test_writer.py
Normal file
@@ -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*"))
|
||||
Reference in New Issue
Block a user