passageway-connector-markdown 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- passageway_connector_markdown-0.3.0/.gitignore +11 -0
- passageway_connector_markdown-0.3.0/PKG-INFO +132 -0
- passageway_connector_markdown-0.3.0/README.md +122 -0
- passageway_connector_markdown-0.3.0/passageway_connector_markdown/__init__.py +1 -0
- passageway_connector_markdown-0.3.0/passageway_connector_markdown/__main__.py +3 -0
- passageway_connector_markdown-0.3.0/passageway_connector_markdown/connector.py +244 -0
- passageway_connector_markdown-0.3.0/pyproject.toml +22 -0
- passageway_connector_markdown-0.3.0/tests/conftest.py +40 -0
- passageway_connector_markdown-0.3.0/tests/test_markdown_connector.py +341 -0
- passageway_connector_markdown-0.3.0/tests/test_markdown_contract.py +80 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: passageway-connector-markdown
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: passageway connector for a folder of markdown files with front matter
|
|
5
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: passageway-sdk~=0.3.0
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# passageway-connector-markdown
|
|
12
|
+
|
|
13
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector that turns a folder of markdown files into searchable items: a docs folder, an Obsidian vault, a Jekyll or Hugo site, or the articles of the frozen Wikipedia corpus.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
- **Each markdown file is one item.** Its id is the path relative to the folder (or an `id` in its front matter) and its version is a hash of the file, so an edit re-indexes just that note.
|
|
18
|
+
- **Front matter gives the metadata:** title, address, date, tags, id and weight (see the table below).
|
|
19
|
+
- **Headings become sections.** Every heading level starts a section whose heading is the trail of its parents, such as `Setup › Linux`, so a hit says where in the note it is.
|
|
20
|
+
- **Folders become the breadcrumb**, and **links between notes become related items:** `[text](other.md)` and `[[Other]]`.
|
|
21
|
+
- **Files that cannot be indexed are skipped, not fatal.** A file that is not UTF-8 text, holds binary data or is too big is skipped with a warning on stderr, and the rest of the folder is indexed.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv tool install \
|
|
29
|
+
--with 'passageway-connector-markdown @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/markdown' \
|
|
30
|
+
git+https://github.com/getpassageway/passageway
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## An example
|
|
34
|
+
|
|
35
|
+
A note `ops/rotating-certificates.md`:
|
|
36
|
+
|
|
37
|
+
```markdown
|
|
38
|
+
---
|
|
39
|
+
title: Rotating certificates
|
|
40
|
+
tags: [ops, security]
|
|
41
|
+
url: https://wiki.example.org/ops/certs
|
|
42
|
+
---
|
|
43
|
+
# Rotating certificates
|
|
44
|
+
|
|
45
|
+
Rotate the signing certificate every year.
|
|
46
|
+
|
|
47
|
+
## Steps
|
|
48
|
+
|
|
49
|
+
1. Create the new certificate.
|
|
50
|
+
2. Follow [the deploy guide](deploy.md).
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
becomes this item (shortened):
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"id": "ops/rotating-certificates.md",
|
|
58
|
+
"title": "Rotating certificates",
|
|
59
|
+
"url": "https://wiki.example.org/ops/certs",
|
|
60
|
+
"path": ["ops"],
|
|
61
|
+
"labels": ["ops", "security"],
|
|
62
|
+
"links": ["ops/deploy.md"],
|
|
63
|
+
"sections": [
|
|
64
|
+
{
|
|
65
|
+
"heading": null,
|
|
66
|
+
"text": "Rotate the signing certificate every year."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"heading": "Steps",
|
|
70
|
+
"text": "1. Create the new certificate.\n2. Follow [the deploy guide](deploy.md)."
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Configure
|
|
77
|
+
|
|
78
|
+
In `passageway.toml` (use the interpreter that has passageway installed, see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
[[connector]]
|
|
82
|
+
name = "notes"
|
|
83
|
+
command = ["/path/to/python", "-m", "passageway_connector_markdown"]
|
|
84
|
+
env = { FOLDER = "/Users/you/notes", EXCLUDE = "drafts/**|*.draft.md" }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Setting | Default | Meaning |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `FOLDER` | required | The folder to index, including its subfolders |
|
|
90
|
+
| `INCLUDE` | `*.md\|*.markdown` | Glob patterns for the files to index, separated by `\|`. `*` stays within a folder, `**` crosses folders, and a pattern without `/` matches the file name at any depth. Replaces the default |
|
|
91
|
+
| `EXCLUDE` | | Glob patterns for files to leave out, the same syntax |
|
|
92
|
+
| `MAX_FILE_BYTES` | `1048576` | Files larger than this are skipped with a warning. The core accepts at most 4 MiB per item line and the output escapes non-ASCII text, which can triple its size, so keep this at or below 1 MiB unless your notes are plain ASCII |
|
|
93
|
+
|
|
94
|
+
Always skipped: hidden files and folders (names starting with `.`, such as `.git` and `.obsidian`) and `node_modules`. Symlinked folders are not followed, and a symlinked file counts only if it points inside `FOLDER`.
|
|
95
|
+
|
|
96
|
+
## Front matter
|
|
97
|
+
|
|
98
|
+
A file may start with a YAML block between `---` lines. These keys are read; everything else is ignored.
|
|
99
|
+
|
|
100
|
+
| Key | Used for |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `title` | The title. Without it: the first `# heading`, then the file name |
|
|
103
|
+
| `url` | The address shown with a hit. Without it: the `file://` address of the file |
|
|
104
|
+
| `updated` | The date shown with a hit. Without it: the file's modification time |
|
|
105
|
+
| `tags` | Labels. A list, or a comma-separated string |
|
|
106
|
+
| `id` | A stable id that survives renames and moves. Without it: the relative path. Two files with the same id fail the call and the message names both |
|
|
107
|
+
| `weight` | A multiplier for ranking, from 0 to 2 (default 1) |
|
|
108
|
+
|
|
109
|
+
All values are read as text, so `no` stays `no` and `2026-10-28` stays a date string. Front matter that is not valid YAML is reported on stderr and the file is indexed without it.
|
|
110
|
+
|
|
111
|
+
## Behavior
|
|
112
|
+
|
|
113
|
+
- **Sections:** every heading level (`#` to `######`) starts a section whose heading is the trail of its parents. Text before the first heading is its own section. The first `# heading` is the title (when the front matter has no title, or the same one) and is not repeated; a different `# heading` stays a section heading. A `#` inside a fenced code block is not a heading.
|
|
114
|
+
- **Links:** `[text](other.md#part)` and `[[Other]]` or `[[Other|text]]` become related items when they point at a note in the folder. Wiki links match the file name without case; a name shared by two files links to neither. Links in code are ignored, and the text itself is not changed.
|
|
115
|
+
- **Version:** a hash of the whole file and of the notes it links to, so any change to the file, front matter included, and a link that starts to resolve (its target was created) re-indexes the note.
|
|
116
|
+
- **Bad files:** a file that is not UTF-8 text (a BOM is removed), contains binary data or is over `MAX_FILE_BYTES` is skipped with a warning on stderr, not an error.
|
|
117
|
+
- **`changed`:** notes modified or moved in since the given time, from the later of each file's modification and change time, so edits appear within a minute, also for files copied in with their old dates.
|
|
118
|
+
- **Cost:** every call reads the whole folder, because ids and links need all the notes. A skipped file is reported on every call.
|
|
119
|
+
|
|
120
|
+
## Not supported
|
|
121
|
+
|
|
122
|
+
- Headings underlined with `===` or `---` (setext); only `#` headings split. A `#` line inside an HTML comment still counts as a heading.
|
|
123
|
+
- Glob character classes (`[ab]`) and braces (`{a,b}`); `*.md` is case sensitive, so `NOTE.MD` is not found without `INCLUDE=*.MD`. A pattern with a `/` is anchored at `FOLDER`, so `drafts/**` matches only the top-level `drafts`; use `**/drafts/**` for any depth.
|
|
124
|
+
- Reference-style links (`[text][ref]`) and brackets inside link text.
|
|
125
|
+
- File names in a different Unicode form than the link (`Café` typed with a combining accent).
|
|
126
|
+
|
|
127
|
+
## Test
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv sync # installs this connector too
|
|
131
|
+
uv run pytest connectors/markdown # offline, on temporary folders
|
|
132
|
+
```
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# passageway-connector-markdown
|
|
2
|
+
|
|
3
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector that turns a folder of markdown files into searchable items: a docs folder, an Obsidian vault, a Jekyll or Hugo site, or the articles of the frozen Wikipedia corpus.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Each markdown file is one item.** Its id is the path relative to the folder (or an `id` in its front matter) and its version is a hash of the file, so an edit re-indexes just that note.
|
|
8
|
+
- **Front matter gives the metadata:** title, address, date, tags, id and weight (see the table below).
|
|
9
|
+
- **Headings become sections.** Every heading level starts a section whose heading is the trail of its parents, such as `Setup › Linux`, so a hit says where in the note it is.
|
|
10
|
+
- **Folders become the breadcrumb**, and **links between notes become related items:** `[text](other.md)` and `[[Other]]`.
|
|
11
|
+
- **Files that cannot be indexed are skipped, not fatal.** A file that is not UTF-8 text, holds binary data or is too big is skipped with a warning on stderr, and the rest of the folder is indexed.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv tool install \
|
|
19
|
+
--with 'passageway-connector-markdown @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/markdown' \
|
|
20
|
+
git+https://github.com/getpassageway/passageway
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## An example
|
|
24
|
+
|
|
25
|
+
A note `ops/rotating-certificates.md`:
|
|
26
|
+
|
|
27
|
+
```markdown
|
|
28
|
+
---
|
|
29
|
+
title: Rotating certificates
|
|
30
|
+
tags: [ops, security]
|
|
31
|
+
url: https://wiki.example.org/ops/certs
|
|
32
|
+
---
|
|
33
|
+
# Rotating certificates
|
|
34
|
+
|
|
35
|
+
Rotate the signing certificate every year.
|
|
36
|
+
|
|
37
|
+
## Steps
|
|
38
|
+
|
|
39
|
+
1. Create the new certificate.
|
|
40
|
+
2. Follow [the deploy guide](deploy.md).
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
becomes this item (shortened):
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"id": "ops/rotating-certificates.md",
|
|
48
|
+
"title": "Rotating certificates",
|
|
49
|
+
"url": "https://wiki.example.org/ops/certs",
|
|
50
|
+
"path": ["ops"],
|
|
51
|
+
"labels": ["ops", "security"],
|
|
52
|
+
"links": ["ops/deploy.md"],
|
|
53
|
+
"sections": [
|
|
54
|
+
{
|
|
55
|
+
"heading": null,
|
|
56
|
+
"text": "Rotate the signing certificate every year."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"heading": "Steps",
|
|
60
|
+
"text": "1. Create the new certificate.\n2. Follow [the deploy guide](deploy.md)."
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configure
|
|
67
|
+
|
|
68
|
+
In `passageway.toml` (use the interpreter that has passageway installed, see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[[connector]]
|
|
72
|
+
name = "notes"
|
|
73
|
+
command = ["/path/to/python", "-m", "passageway_connector_markdown"]
|
|
74
|
+
env = { FOLDER = "/Users/you/notes", EXCLUDE = "drafts/**|*.draft.md" }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Setting | Default | Meaning |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `FOLDER` | required | The folder to index, including its subfolders |
|
|
80
|
+
| `INCLUDE` | `*.md\|*.markdown` | Glob patterns for the files to index, separated by `\|`. `*` stays within a folder, `**` crosses folders, and a pattern without `/` matches the file name at any depth. Replaces the default |
|
|
81
|
+
| `EXCLUDE` | | Glob patterns for files to leave out, the same syntax |
|
|
82
|
+
| `MAX_FILE_BYTES` | `1048576` | Files larger than this are skipped with a warning. The core accepts at most 4 MiB per item line and the output escapes non-ASCII text, which can triple its size, so keep this at or below 1 MiB unless your notes are plain ASCII |
|
|
83
|
+
|
|
84
|
+
Always skipped: hidden files and folders (names starting with `.`, such as `.git` and `.obsidian`) and `node_modules`. Symlinked folders are not followed, and a symlinked file counts only if it points inside `FOLDER`.
|
|
85
|
+
|
|
86
|
+
## Front matter
|
|
87
|
+
|
|
88
|
+
A file may start with a YAML block between `---` lines. These keys are read; everything else is ignored.
|
|
89
|
+
|
|
90
|
+
| Key | Used for |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `title` | The title. Without it: the first `# heading`, then the file name |
|
|
93
|
+
| `url` | The address shown with a hit. Without it: the `file://` address of the file |
|
|
94
|
+
| `updated` | The date shown with a hit. Without it: the file's modification time |
|
|
95
|
+
| `tags` | Labels. A list, or a comma-separated string |
|
|
96
|
+
| `id` | A stable id that survives renames and moves. Without it: the relative path. Two files with the same id fail the call and the message names both |
|
|
97
|
+
| `weight` | A multiplier for ranking, from 0 to 2 (default 1) |
|
|
98
|
+
|
|
99
|
+
All values are read as text, so `no` stays `no` and `2026-10-28` stays a date string. Front matter that is not valid YAML is reported on stderr and the file is indexed without it.
|
|
100
|
+
|
|
101
|
+
## Behavior
|
|
102
|
+
|
|
103
|
+
- **Sections:** every heading level (`#` to `######`) starts a section whose heading is the trail of its parents. Text before the first heading is its own section. The first `# heading` is the title (when the front matter has no title, or the same one) and is not repeated; a different `# heading` stays a section heading. A `#` inside a fenced code block is not a heading.
|
|
104
|
+
- **Links:** `[text](other.md#part)` and `[[Other]]` or `[[Other|text]]` become related items when they point at a note in the folder. Wiki links match the file name without case; a name shared by two files links to neither. Links in code are ignored, and the text itself is not changed.
|
|
105
|
+
- **Version:** a hash of the whole file and of the notes it links to, so any change to the file, front matter included, and a link that starts to resolve (its target was created) re-indexes the note.
|
|
106
|
+
- **Bad files:** a file that is not UTF-8 text (a BOM is removed), contains binary data or is over `MAX_FILE_BYTES` is skipped with a warning on stderr, not an error.
|
|
107
|
+
- **`changed`:** notes modified or moved in since the given time, from the later of each file's modification and change time, so edits appear within a minute, also for files copied in with their old dates.
|
|
108
|
+
- **Cost:** every call reads the whole folder, because ids and links need all the notes. A skipped file is reported on every call.
|
|
109
|
+
|
|
110
|
+
## Not supported
|
|
111
|
+
|
|
112
|
+
- Headings underlined with `===` or `---` (setext); only `#` headings split. A `#` line inside an HTML comment still counts as a heading.
|
|
113
|
+
- Glob character classes (`[ab]`) and braces (`{a,b}`); `*.md` is case sensitive, so `NOTE.MD` is not found without `INCLUDE=*.MD`. A pattern with a `/` is anchored at `FOLDER`, so `drafts/**` matches only the top-level `drafts`; use `**/drafts/**` for any depth.
|
|
114
|
+
- Reference-style links (`[text][ref]`) and brackets inside link text.
|
|
115
|
+
- File names in a different Unicode form than the link (`Café` typed with a combining accent).
|
|
116
|
+
|
|
117
|
+
## Test
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
uv sync # installs this connector too
|
|
121
|
+
uv run pytest connectors/markdown # offline, on temporary folders
|
|
122
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""passageway connector for a folder of markdown files with front matter."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import posixpath
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterator, Mapping
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from passageway_sdk import Connector, SettingsError, SourceError, first_h1, split_markdown, strip_code
|
|
15
|
+
from passageway_sdk.settings import names, whole
|
|
16
|
+
|
|
17
|
+
DEFAULT_INCLUDE = ("*.md", "*.markdown")
|
|
18
|
+
SKIP_DIRS = frozenset({"node_modules"})
|
|
19
|
+
FRONT_MATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)?(?:---|\.\.\.)[ \t]*(?:\r?\n|\Z)", re.S)
|
|
20
|
+
# [[wiki link]] or [text](target); no brackets or line breaks inside, which also keeps the matching linear
|
|
21
|
+
LINK = re.compile(r"\[\[([^\[\]\n]+)\]\]|\[[^\[\]\n]*\]\(([^()\n]*)\)")
|
|
22
|
+
SCHEME = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _glob(pattern: str) -> re.Pattern:
|
|
26
|
+
"""A glob over the path relative to the folder. `**` crosses folders; a pattern without `/` matches the file name anywhere."""
|
|
27
|
+
out, i = "", 0
|
|
28
|
+
while i < len(pattern):
|
|
29
|
+
if pattern.startswith("**/", i):
|
|
30
|
+
out, i = out + "(?:.*/)?", i + 3
|
|
31
|
+
elif pattern.startswith("**", i):
|
|
32
|
+
out, i = out + ".*", i + 2
|
|
33
|
+
elif pattern[i] == "*":
|
|
34
|
+
out, i = out + "[^/]*", i + 1
|
|
35
|
+
elif pattern[i] == "?":
|
|
36
|
+
out, i = out + "[^/]", i + 1
|
|
37
|
+
else:
|
|
38
|
+
out, i = out + re.escape(pattern[i]), i + 1
|
|
39
|
+
return re.compile(("" if "/" in pattern else "(?:.*/)?") + out + r"\Z")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Note:
|
|
44
|
+
rel: str # the path relative to the folder, with `/`
|
|
45
|
+
path: Path
|
|
46
|
+
id: str
|
|
47
|
+
meta: dict
|
|
48
|
+
body: str
|
|
49
|
+
version: str # the content and the links it resolves to: a link that starts to resolve changes it
|
|
50
|
+
mtime: float
|
|
51
|
+
changed: float # the later of modification and change time: a note moved in keeps its old mtime
|
|
52
|
+
links: tuple[str, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Markdown(Connector):
|
|
56
|
+
name = "markdown connector"
|
|
57
|
+
expected_errors = (OSError,)
|
|
58
|
+
|
|
59
|
+
def __init__(self, env: Mapping[str, str]):
|
|
60
|
+
folder = env.get("FOLDER", "").strip()
|
|
61
|
+
if not folder or not Path(folder).is_dir():
|
|
62
|
+
raise SettingsError(f"FOLDER must be an existing folder, got {folder!r}")
|
|
63
|
+
self.root = Path(folder).resolve()
|
|
64
|
+
self.include = [_glob(p) for p in names(env, "INCLUDE") or DEFAULT_INCLUDE]
|
|
65
|
+
self.exclude = [_glob(p) for p in names(env, "EXCLUDE")]
|
|
66
|
+
self.max_bytes = whole(env, "MAX_FILE_BYTES", 1024 * 1024)
|
|
67
|
+
|
|
68
|
+
# -- the protocol ------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def versions(self) -> dict[str, str]:
|
|
71
|
+
return {note.id: note.version for note in self._notes().values()}
|
|
72
|
+
|
|
73
|
+
def fetch(self, ids: list[str]) -> Iterator[dict]:
|
|
74
|
+
notes = self._notes()
|
|
75
|
+
for item_id in ids:
|
|
76
|
+
if item_id in notes:
|
|
77
|
+
yield self._item(notes[item_id])
|
|
78
|
+
|
|
79
|
+
def changed(self, since: datetime) -> Iterator[dict]:
|
|
80
|
+
# ponytail: every call reads the whole folder (ids and links need it); a cache if a vault ever has tens of thousands of notes
|
|
81
|
+
for note in self._notes().values():
|
|
82
|
+
if note.changed >= since.timestamp():
|
|
83
|
+
yield self._item(note)
|
|
84
|
+
|
|
85
|
+
# -- the folder --------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _wanted(self, rel: str) -> bool:
|
|
88
|
+
return any(g.match(rel) for g in self.include) and not any(g.match(rel) for g in self.exclude)
|
|
89
|
+
|
|
90
|
+
def _paths(self) -> Iterator[tuple[str, Path]]:
|
|
91
|
+
def unreadable(e: OSError) -> None:
|
|
92
|
+
self.warn(f"skipped {Path(e.filename).relative_to(self.root).as_posix()}/: {e.strerror}")
|
|
93
|
+
|
|
94
|
+
for dirpath, dirnames, filenames in os.walk(self.root, onerror=unreadable): # does not follow symlinked folders
|
|
95
|
+
dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS
|
|
96
|
+
and not os.path.islink(os.path.join(dirpath, d)))
|
|
97
|
+
for name in sorted(filenames):
|
|
98
|
+
path = Path(dirpath, name)
|
|
99
|
+
rel = path.relative_to(self.root).as_posix()
|
|
100
|
+
if name.startswith(".") or not self._wanted(rel) or not path.is_file():
|
|
101
|
+
continue
|
|
102
|
+
if path.is_symlink() and not path.resolve().is_relative_to(self.root):
|
|
103
|
+
continue # a link out of the folder would index files that are not in it
|
|
104
|
+
yield rel, path
|
|
105
|
+
|
|
106
|
+
def _notes(self) -> dict[str, Note]:
|
|
107
|
+
notes: dict[str, Note] = {}
|
|
108
|
+
for rel, path in self._paths():
|
|
109
|
+
note = self._read(rel, path)
|
|
110
|
+
if note is None:
|
|
111
|
+
continue
|
|
112
|
+
if note.id in notes:
|
|
113
|
+
raise SourceError(f"two notes have the id {note.id!r}: {notes[note.id].rel} and {rel}")
|
|
114
|
+
notes[note.id] = note
|
|
115
|
+
finder = _Links(notes)
|
|
116
|
+
for item_id, note in notes.items():
|
|
117
|
+
found = tuple(finder.of(note))
|
|
118
|
+
digest = hashlib.sha256((note.version + "\n".join(found)).encode()).hexdigest()[:16]
|
|
119
|
+
notes[item_id] = replace(note, links=found, version=digest)
|
|
120
|
+
return notes
|
|
121
|
+
|
|
122
|
+
def _read(self, rel: str, path: Path) -> Note | None:
|
|
123
|
+
try:
|
|
124
|
+
stat = path.stat()
|
|
125
|
+
if stat.st_size > self.max_bytes:
|
|
126
|
+
self.warn(f"skipped {rel}: larger than MAX_FILE_BYTES ({self.max_bytes})")
|
|
127
|
+
return None
|
|
128
|
+
raw = path.read_bytes()
|
|
129
|
+
except OSError as e: # gone since the listing, or unreadable
|
|
130
|
+
self.warn(f"skipped {rel}: {e.strerror or e}")
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
if b"\0" in raw:
|
|
134
|
+
raise UnicodeDecodeError("utf-8", b"", 0, 1, "binary data")
|
|
135
|
+
text = raw.decode("utf-8-sig") # also removes a BOM
|
|
136
|
+
except UnicodeDecodeError:
|
|
137
|
+
self.warn(f"skipped {rel}: not UTF-8 text")
|
|
138
|
+
return None
|
|
139
|
+
meta, body = self._front_matter(rel, text)
|
|
140
|
+
return Note(rel, path, _text(meta, "id") or rel, meta, body, hashlib.sha256(raw).hexdigest(), stat.st_mtime,
|
|
141
|
+
max(stat.st_mtime, stat.st_ctime))
|
|
142
|
+
|
|
143
|
+
def _front_matter(self, rel: str, text: str) -> tuple[dict, str]:
|
|
144
|
+
match = FRONT_MATTER.match(text)
|
|
145
|
+
if not match:
|
|
146
|
+
return {}, text
|
|
147
|
+
body = text[match.end():]
|
|
148
|
+
try:
|
|
149
|
+
# BaseLoader keeps every scalar a string, so `no` stays "no" and a date stays "2026-10-28"; it builds no objects
|
|
150
|
+
meta = yaml.load(match.group(1) or "", Loader=yaml.BaseLoader)
|
|
151
|
+
except (yaml.YAMLError, RecursionError): # RecursionError: a few thousand nested brackets
|
|
152
|
+
self.warn(f"{rel}: front matter is not valid YAML, indexed without it")
|
|
153
|
+
return {}, body
|
|
154
|
+
if meta is not None and not isinstance(meta, dict):
|
|
155
|
+
self.warn(f"{rel}: front matter is not a mapping of keys, indexed without it")
|
|
156
|
+
return {}, body
|
|
157
|
+
return meta or {}, body
|
|
158
|
+
|
|
159
|
+
# -- the item ----------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _item(self, note: Note) -> dict:
|
|
162
|
+
meta = note.meta
|
|
163
|
+
title = _text(meta, "title")
|
|
164
|
+
h1 = first_h1(note.body)
|
|
165
|
+
is_title = h1 is not None and (not title or h1.casefold() == title.casefold()) # then it is not repeated as a heading
|
|
166
|
+
item = {
|
|
167
|
+
"id": note.id,
|
|
168
|
+
"title": title or h1 or PurePosixPath(note.rel).stem,
|
|
169
|
+
"url": _text(meta, "url") or note.path.as_uri(),
|
|
170
|
+
"version": note.version,
|
|
171
|
+
"updated": _text(meta, "updated") or datetime.fromtimestamp(note.mtime, UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
172
|
+
"sections": split_markdown(note.body, skip_first_h1=is_title),
|
|
173
|
+
}
|
|
174
|
+
if folders := list(PurePosixPath(note.rel).parent.parts):
|
|
175
|
+
item["path"] = folders
|
|
176
|
+
if labels := _tags(meta.get("tags")):
|
|
177
|
+
item["labels"] = labels
|
|
178
|
+
if note.links:
|
|
179
|
+
item["links"] = list(note.links)
|
|
180
|
+
weight = _text(meta, "weight")
|
|
181
|
+
if weight is not None:
|
|
182
|
+
try:
|
|
183
|
+
value = float(weight)
|
|
184
|
+
if not 0 <= value <= 2:
|
|
185
|
+
raise ValueError
|
|
186
|
+
item["weight"] = value
|
|
187
|
+
except ValueError:
|
|
188
|
+
self.warn(f"{note.rel}: weight must be a number from 0 to 2, got {weight!r}; ignored")
|
|
189
|
+
return item
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _text(meta: dict, key: str) -> str | None:
|
|
193
|
+
value = meta.get(key)
|
|
194
|
+
return value.strip() or None if isinstance(value, str) else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _tags(value) -> list[str]:
|
|
198
|
+
if isinstance(value, str):
|
|
199
|
+
value = value.split(",")
|
|
200
|
+
return [t.strip() for t in value if isinstance(t, str) and t.strip()] if isinstance(value, list) else []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class _Links:
|
|
204
|
+
"""Finds the notes a note links to: by path for [text](file.md), by file name for [[Name]]."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, notes: dict[str, Note]):
|
|
207
|
+
self.by_path = {n.rel: n.id for n in notes.values()}
|
|
208
|
+
stems: dict[str, list[str]] = {}
|
|
209
|
+
for n in notes.values():
|
|
210
|
+
stems.setdefault(PurePosixPath(n.rel).stem.casefold(), []).append(n.id)
|
|
211
|
+
self.by_stem = {stem: ids[0] for stem, ids in stems.items() if len(ids) == 1} # an ambiguous name links to nothing
|
|
212
|
+
|
|
213
|
+
def of(self, note: Note) -> list[str]:
|
|
214
|
+
found: list[str] = []
|
|
215
|
+
for wiki, target in LINK.findall(strip_code(note.body)):
|
|
216
|
+
item_id = self._wiki(wiki) if wiki else self._path(note, target)
|
|
217
|
+
if item_id and item_id != note.id and item_id not in found:
|
|
218
|
+
found.append(item_id)
|
|
219
|
+
return found
|
|
220
|
+
|
|
221
|
+
def _wiki(self, wiki: str) -> str | None:
|
|
222
|
+
target = re.split(r"\\?\|", wiki)[0].split("#")[0].strip() # `\|` is how a table cell escapes the pipe
|
|
223
|
+
target = target[:-3] if target.lower().endswith(".md") else target
|
|
224
|
+
for candidate in (f"{target}.md", f"{target}.markdown"): # [[folder/Name]] names the folder when the name is ambiguous
|
|
225
|
+
if candidate in self.by_path:
|
|
226
|
+
return self.by_path[candidate]
|
|
227
|
+
return self.by_stem.get(PurePosixPath(target).name.casefold())
|
|
228
|
+
|
|
229
|
+
def _path(self, note: Note, target: str) -> str | None:
|
|
230
|
+
target = target.strip()
|
|
231
|
+
if target.startswith("<") and ">" in target: # <a target with spaces> and an optional title after it
|
|
232
|
+
target = target[1:target.index(">")]
|
|
233
|
+
else:
|
|
234
|
+
target = target.split()[0] if target else ""
|
|
235
|
+
target = unquote(target.split("#")[0].split("?")[0])
|
|
236
|
+
if not target or SCHEME.match(target):
|
|
237
|
+
return None
|
|
238
|
+
joined = target.lstrip("/") if target.startswith("/") else posixpath.join(posixpath.dirname(note.rel), target)
|
|
239
|
+
resolved = posixpath.normpath(joined)
|
|
240
|
+
return None if resolved.startswith("..") else self.by_path.get(resolved)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main() -> None:
|
|
244
|
+
Markdown.main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "passageway-connector-markdown"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "passageway connector for a folder of markdown files with front matter"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "PolyForm-Noncommercial-1.0.0"
|
|
12
|
+
dependencies = ["passageway-sdk~=0.3.0", "pyyaml>=6.0"]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["passageway_connector_markdown"]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["jsonschema>=4.23", "pytest>=8.3", "passageway"]
|
|
19
|
+
|
|
20
|
+
[tool.uv.sources]
|
|
21
|
+
passageway = { workspace = true }
|
|
22
|
+
passageway-sdk = { workspace = true }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from passageway_connector_markdown.connector import Markdown
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Folder:
|
|
10
|
+
"""A folder of notes and a way to run the connector on it in-process."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, root):
|
|
13
|
+
self.root = root
|
|
14
|
+
|
|
15
|
+
def write(self, name, text, *, binary=False):
|
|
16
|
+
path = self.root / name
|
|
17
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
path.write_bytes(text) if binary else path.write_text(text, encoding="utf-8")
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
def call(self, mode, stdin="", *, extra=(), **settings):
|
|
22
|
+
out, err = io.StringIO(), io.StringIO()
|
|
23
|
+
env = {"FOLDER": str(self.root), **settings}
|
|
24
|
+
code = Markdown.run(["markdown", mode, *extra], env, io.StringIO(stdin), out, err)
|
|
25
|
+
return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
|
|
26
|
+
|
|
27
|
+
def fetch(self, *ids, **settings):
|
|
28
|
+
code, records, err = self.call("fetch", "".join(json.dumps({"id": i}) + "\n" for i in ids), **settings)
|
|
29
|
+
assert code == 0, err
|
|
30
|
+
return {r["id"]: r for r in records}
|
|
31
|
+
|
|
32
|
+
def ids(self, **settings):
|
|
33
|
+
code, records, err = self.call("list", **settings)
|
|
34
|
+
assert code == 0, err
|
|
35
|
+
return sorted(r["id"] for r in records)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def folder(tmp_path):
|
|
40
|
+
return Folder(tmp_path)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from passageway.protocol import parse_item
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ---- which files ----------------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
def test_markdown_files_are_found_in_subfolders_and_other_files_are_not(folder):
|
|
13
|
+
folder.write("a.md", "A")
|
|
14
|
+
folder.write("sub/b.markdown", "B")
|
|
15
|
+
folder.write("c.txt", "C")
|
|
16
|
+
folder.write("sub/deeper/d.md", "D")
|
|
17
|
+
assert folder.ids() == ["a.md", "sub/b.markdown", "sub/deeper/d.md"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_hidden_folders_and_files_and_node_modules_are_always_skipped(folder):
|
|
21
|
+
folder.write("keep.md", "K")
|
|
22
|
+
folder.write(".git/notes.md", "x")
|
|
23
|
+
folder.write(".obsidian/plugins/readme.md", "x")
|
|
24
|
+
folder.write(".hidden.md", "x")
|
|
25
|
+
folder.write("node_modules/pkg/readme.md", "x")
|
|
26
|
+
folder.write("sub/.trash/old.md", "x")
|
|
27
|
+
assert folder.ids() == ["keep.md"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_a_symlinked_folder_is_not_followed_and_a_symlink_loop_ends(folder):
|
|
31
|
+
folder.write("real/a.md", "A")
|
|
32
|
+
os.symlink(folder.root / "real", folder.root / "alias")
|
|
33
|
+
os.symlink(folder.root, folder.root / "real" / "loop")
|
|
34
|
+
assert folder.ids() == ["real/a.md"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_a_symlinked_file_counts_only_if_it_points_inside_the_folder(folder, tmp_path_factory):
|
|
38
|
+
outside = tmp_path_factory.mktemp("outside") / "secret.md"
|
|
39
|
+
outside.write_text("secret")
|
|
40
|
+
folder.write("inside.md", "I")
|
|
41
|
+
os.symlink(folder.root / "inside.md", folder.root / "link-in.md")
|
|
42
|
+
os.symlink(outside, folder.root / "link-out.md")
|
|
43
|
+
assert folder.ids() == ["inside.md", "link-in.md"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_include_replaces_the_default_and_exclude_removes(folder):
|
|
47
|
+
for name in ("a.md", "docs/b.md", "docs/c.md", "docs/c.draft.md", "other/d.md", "e.txt"):
|
|
48
|
+
folder.write(name, "x")
|
|
49
|
+
assert folder.ids(INCLUDE="docs/**/*.md") == ["docs/b.md", "docs/c.draft.md", "docs/c.md"]
|
|
50
|
+
assert folder.ids(INCLUDE="docs/*.md|a.md") == ["a.md", "docs/b.md", "docs/c.draft.md", "docs/c.md"]
|
|
51
|
+
assert folder.ids(EXCLUDE="*.draft.md|other/**") == ["a.md", "docs/b.md", "docs/c.md"] # no slash: any depth
|
|
52
|
+
assert folder.ids(INCLUDE="*.txt") == ["e.txt"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---- the item --------------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def test_the_item_is_valid_and_has_the_path_as_id_and_a_content_hash_as_version(folder):
|
|
58
|
+
folder.write("guides/setup.md", "# Setup guide\n\nInstall it.\n")
|
|
59
|
+
item = folder.fetch("guides/setup.md")["guides/setup.md"]
|
|
60
|
+
parsed = parse_item(item) # the core's own parser accepts it
|
|
61
|
+
assert parsed.id == "guides/setup.md" and parsed.title == "Setup guide"
|
|
62
|
+
assert parsed.path == ("guides",) and parsed.url.startswith("file://") and parsed.url.endswith("/guides/setup.md")
|
|
63
|
+
assert [(s.heading, s.text) for s in parsed.sections] == [(None, "Install it.")]
|
|
64
|
+
folder.write("guides/setup.md", "# Setup guide\n\nInstall it, then run it.\n")
|
|
65
|
+
assert folder.fetch("guides/setup.md")["guides/setup.md"]["version"] != item["version"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_the_version_ignores_nothing_a_change_to_the_front_matter_changes_it(folder):
|
|
69
|
+
folder.write("a.md", "---\ntags: [x]\n---\nBody")
|
|
70
|
+
before = folder.fetch("a.md")["a.md"]["version"]
|
|
71
|
+
folder.write("a.md", "---\ntags: [y]\n---\nBody")
|
|
72
|
+
assert folder.fetch("a.md")["a.md"]["version"] != before
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_the_title_is_the_front_matter_title_else_the_first_heading_else_the_file_name(folder):
|
|
76
|
+
folder.write("a.md", "---\ntitle: From front matter\n---\n# Heading\nbody")
|
|
77
|
+
folder.write("b.md", "intro\n\n# From heading\nbody")
|
|
78
|
+
folder.write("c-note.md", "just text")
|
|
79
|
+
got = folder.fetch("a.md", "b.md", "c-note.md")
|
|
80
|
+
assert [got[i]["title"] for i in ("a.md", "b.md", "c-note.md")] == ["From front matter", "From heading", "c-note"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_front_matter_gives_url_updated_tags_weight_and_id(folder):
|
|
84
|
+
folder.write("a.md", "---\nurl: https://example.org/a\nupdated: 2026-10-05\ntags: [ops, security]\n"
|
|
85
|
+
"weight: 1.5\nid: stable-a\n---\nBody")
|
|
86
|
+
item = folder.fetch("stable-a")["stable-a"]
|
|
87
|
+
assert (item["url"], item["updated"], item["labels"], item["weight"]) == (
|
|
88
|
+
"https://example.org/a", "2026-10-05", ["ops", "security"], 1.5)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_tags_may_be_a_comma_separated_string_and_scalars_stay_text(folder):
|
|
92
|
+
folder.write("a.md", "---\ntags: ops, security\n---\nBody")
|
|
93
|
+
folder.write("b.md", "---\ntags:\n - no\n - 2026-10-28\n---\nBody") # YAML 1.1 would make these False and a date
|
|
94
|
+
got = folder.fetch("a.md", "b.md")
|
|
95
|
+
assert got["a.md"]["labels"] == ["ops", "security"] and got["b.md"]["labels"] == ["no", "2026-10-28"]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_updated_defaults_to_the_modification_time_in_utc(folder):
|
|
99
|
+
path = folder.write("a.md", "Body")
|
|
100
|
+
os.utime(path, (1_700_000_000, 1_700_000_000))
|
|
101
|
+
assert folder.fetch("a.md")["a.md"]["updated"] == "2023-11-14T22:13:20Z"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_the_front_matter_is_not_indexed_as_text(folder):
|
|
105
|
+
folder.write("a.md", "---\ntitle: T\nsecret_key: zebra-key\n---\nBody text")
|
|
106
|
+
sections = folder.fetch("a.md")["a.md"]["sections"]
|
|
107
|
+
assert [s["text"] for s in sections] == ["Body text"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_broken_front_matter_warns_and_the_note_is_still_indexed(folder):
|
|
111
|
+
folder.write("a.md", "---\ntitle: [unclosed\n---\nBody text")
|
|
112
|
+
code, records, err = folder.call("fetch", '{"id": "a.md"}\n')
|
|
113
|
+
assert code == 0 and records[0]["title"] == "a" and records[0]["sections"][0]["text"] == "Body text"
|
|
114
|
+
assert "a.md" in err and "front matter" in err
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_a_front_matter_that_is_not_a_mapping_is_ignored_with_a_warning(folder):
|
|
118
|
+
folder.write("a.md", "---\n- just\n- a list\n---\nBody")
|
|
119
|
+
code, records, err = folder.call("fetch", '{"id": "a.md"}\n')
|
|
120
|
+
assert code == 0 and records[0]["sections"][0]["text"] == "Body" and "front matter" in err
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_an_unclosed_fence_means_no_front_matter(folder):
|
|
124
|
+
folder.write("a.md", "---\nnot front matter, a rule above text\n")
|
|
125
|
+
assert folder.fetch("a.md")["a.md"]["sections"][0]["text"].startswith("---")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_a_bad_weight_is_ignored_with_a_warning(folder):
|
|
129
|
+
folder.write("a.md", "---\nweight: heavy\n---\nBody")
|
|
130
|
+
code, records, err = folder.call("fetch", '{"id": "a.md"}\n')
|
|
131
|
+
assert code == 0 and "weight" not in records[0] and "weight" in err
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_sections_follow_the_headings_and_the_first_h1_is_the_title(folder):
|
|
135
|
+
folder.write("a.md", "# Guide\nIntro.\n\n## Setup\nInstall.\n\n### Linux\nApt.\n")
|
|
136
|
+
item = folder.fetch("a.md")["a.md"]
|
|
137
|
+
assert item["title"] == "Guide"
|
|
138
|
+
assert [(s["heading"], s["text"]) for s in item["sections"]] == [
|
|
139
|
+
(None, "Intro."), ("Setup", "Install."), ("Setup › Linux", "Apt.")]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---- ids ------------------------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def test_two_notes_with_the_same_id_fail_the_call_naming_both(folder):
|
|
145
|
+
folder.write("a.md", "---\nid: same\n---\nA")
|
|
146
|
+
folder.write("sub/b.md", "---\nid: same\n---\nB")
|
|
147
|
+
code, records, err = folder.call("list")
|
|
148
|
+
assert code == 1 and records == [] and "same" in err and "a.md" in err and "sub/b.md" in err
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---- links ----------------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def test_links_to_other_notes_become_ids_of_existing_files(folder):
|
|
154
|
+
folder.write("guides/setup.md", "Setup")
|
|
155
|
+
folder.write("guides/usage.md", "Usage")
|
|
156
|
+
folder.write("index.md", "See [setup](guides/setup.md#linux), [usage](guides/usage.md?x=1), "
|
|
157
|
+
"[web](https://example.org/x.md), [gone](missing.md), [anchor](#top), "
|
|
158
|
+
"and [encoded](guides/set%75p.md).")
|
|
159
|
+
assert folder.fetch("index.md")["index.md"]["links"] == ["guides/setup.md", "guides/usage.md"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_relative_links_resolve_from_the_notes_own_folder(folder):
|
|
163
|
+
folder.write("a/one.md", "[two](../b/two.md) and [three](./three.md)")
|
|
164
|
+
folder.write("b/two.md", "Two")
|
|
165
|
+
folder.write("a/three.md", "Three")
|
|
166
|
+
assert folder.fetch("a/one.md")["a/one.md"]["links"] == ["b/two.md", "a/three.md"]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_wiki_links_match_the_file_name_ignoring_case_and_skip_ambiguous_names(folder):
|
|
170
|
+
folder.write("Setup.md", "S")
|
|
171
|
+
folder.write("x/Dup.md", "D1")
|
|
172
|
+
folder.write("y/dup.md", "D2")
|
|
173
|
+
folder.write("n.md", "[[setup]] [[Setup|the guide]] [[Dup]] [[Nothing]] [[Setup#Linux]]")
|
|
174
|
+
assert folder.fetch("n.md")["n.md"]["links"] == ["Setup.md"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_links_use_front_matter_ids_and_ignore_code(folder):
|
|
178
|
+
folder.write("target.md", "---\nid: stable-target\n---\nT")
|
|
179
|
+
folder.write("other.md", "O")
|
|
180
|
+
folder.write("n.md", "[t](target.md)\n```\n[o](other.md)\n```\nand `[o](other.md)` inline")
|
|
181
|
+
assert folder.fetch("n.md")["n.md"]["links"] == ["stable-target"]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_a_note_does_not_link_to_itself(folder):
|
|
185
|
+
folder.write("a.md", "[me](a.md) [[a]]")
|
|
186
|
+
assert "links" not in folder.fetch("a.md")["a.md"]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---- bad files --------------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def test_a_bom_is_removed(folder):
|
|
192
|
+
folder.write("a.md", b"\xef\xbb\xbf# Title\n\nBody", binary=True)
|
|
193
|
+
item = folder.fetch("a.md")["a.md"]
|
|
194
|
+
assert item["title"] == "Title" and item["sections"][0]["text"] == "Body"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_files_that_are_not_text_are_skipped_with_a_warning(folder):
|
|
198
|
+
folder.write("ok.md", "fine")
|
|
199
|
+
folder.write("latin.md", "caf\xe9".encode("latin-1"), binary=True)
|
|
200
|
+
folder.write("binary.md", b"abc\x00def", binary=True)
|
|
201
|
+
code, records, err = folder.call("list")
|
|
202
|
+
assert code == 0 and [r["id"] for r in records] == ["ok.md"]
|
|
203
|
+
assert "latin.md" in err and "binary.md" in err
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_a_file_over_the_limit_is_skipped_with_a_warning(folder):
|
|
207
|
+
folder.write("small.md", "x" * 100)
|
|
208
|
+
folder.write("big.md", "x" * 101)
|
|
209
|
+
code, records, err = folder.call("list", MAX_FILE_BYTES="100")
|
|
210
|
+
assert code == 0 and [r["id"] for r in records] == ["small.md"] and "big.md" in err
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_a_file_that_vanishes_between_list_and_fetch_is_skipped(folder):
|
|
214
|
+
path = folder.write("a.md", "A")
|
|
215
|
+
folder.write("b.md", "B")
|
|
216
|
+
assert folder.ids() == ["a.md", "b.md"]
|
|
217
|
+
path.unlink()
|
|
218
|
+
assert list(folder.fetch("a.md", "b.md")) == ["b.md"]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_non_ascii_text_is_written_ascii_safe(folder):
|
|
222
|
+
folder.write("a.md", "# Köln\n\nGröße", )
|
|
223
|
+
code, records, _ = folder.call("fetch", '{"id": "a.md"}\n')
|
|
224
|
+
assert records[0]["title"] == "Köln" and records[0]["sections"][0]["text"] == "Größe"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---- check and changed ------------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def test_check_counts_the_notes(folder):
|
|
230
|
+
folder.write("a.md", "A")
|
|
231
|
+
folder.write("b.md", "B")
|
|
232
|
+
assert folder.call("check")[:2] == (0, [{"ok": True, "count": 2, "protocol": "1"}])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_changed_returns_the_notes_modified_or_moved_in_since(folder):
|
|
236
|
+
soon = int(time.time()) + 3600 # whole seconds: a fraction can be stored a few nanoseconds short
|
|
237
|
+
old = folder.write("old.md", "Old")
|
|
238
|
+
new = folder.write("new.md", "New")
|
|
239
|
+
os.utime(old, (1_700_000_000, 1_700_000_000)) # an old mtime, but its ctime is now: it was just put here
|
|
240
|
+
os.utime(new, (soon, soon))
|
|
241
|
+
now = datetime.fromtimestamp(time.time() - 60, timezone.utc).isoformat()
|
|
242
|
+
assert sorted(r["id"] for r in folder.call("changed", extra=["--since", now])[1]) == ["new.md", "old.md"]
|
|
243
|
+
later = datetime.fromtimestamp(time.time() + 60, timezone.utc).isoformat() # after the move, before the mtime of new
|
|
244
|
+
assert [r["id"] for r in folder.call("changed", extra=["--since", later])[1]] == ["new.md"]
|
|
245
|
+
exact = datetime.fromtimestamp(soon, timezone.utc).isoformat()
|
|
246
|
+
assert [r["id"] for r in folder.call("changed", extra=["--since", exact])[1]] == ["new.md"] # inclusive
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---- settings ---------------------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
@pytest.mark.parametrize("settings,message", [
|
|
252
|
+
({"FOLDER": ""}, "FOLDER"),
|
|
253
|
+
({"FOLDER": "/no/such/folder"}, "FOLDER"),
|
|
254
|
+
({"MAX_FILE_BYTES": "lots"}, "MAX_FILE_BYTES"),
|
|
255
|
+
({"MAX_FILE_BYTES": "0"}, "MAX_FILE_BYTES"),
|
|
256
|
+
])
|
|
257
|
+
def test_bad_settings_exit_2_with_a_message_and_no_output(folder, settings, message):
|
|
258
|
+
import io
|
|
259
|
+
|
|
260
|
+
from passageway_connector_markdown.connector import Markdown
|
|
261
|
+
out, err = io.StringIO(), io.StringIO()
|
|
262
|
+
env = {"FOLDER": str(folder.root), **settings}
|
|
263
|
+
assert Markdown.run(["markdown", "list"], env, io.StringIO(), out, err) == 2
|
|
264
|
+
assert out.getvalue() == "" and message in err.getvalue()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---- review findings ------------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def test_deeply_nested_front_matter_warns_and_does_not_fail_the_source(folder):
|
|
270
|
+
folder.write("deep.md", "---\nx: " + "[" * 5000 + "]" * 5000 + "\n---\nBody")
|
|
271
|
+
folder.write("good.md", "Good")
|
|
272
|
+
code, records, err = folder.call("list")
|
|
273
|
+
assert code == 0 and sorted(r["id"] for r in records) == ["deep.md", "good.md"] and "deep.md" in err
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.mark.parametrize("text", ["[" * 300_000, "[[" * 150_000, "[a](" * 75_000])
|
|
277
|
+
def test_a_pathological_line_does_not_make_link_finding_slow(folder, text):
|
|
278
|
+
folder.write("a.md", text)
|
|
279
|
+
started = time.monotonic()
|
|
280
|
+
assert folder.fetch("a.md")["a.md"]["id"] == "a.md"
|
|
281
|
+
assert time.monotonic() - started < 5
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_the_version_changes_when_a_link_starts_to_resolve(folder):
|
|
285
|
+
folder.write("a.md", "see [[New Idea]] and [x](other.md)")
|
|
286
|
+
before = folder.call("list")[1][0]["version"]
|
|
287
|
+
folder.write("New Idea.md", "N")
|
|
288
|
+
folder.write("other.md", "O")
|
|
289
|
+
after = {r["id"]: r["version"] for r in folder.call("list")[1]}
|
|
290
|
+
assert after["a.md"] != before
|
|
291
|
+
assert folder.fetch("a.md")["a.md"]["links"] == ["New Idea.md", "other.md"]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_an_h1_that_is_not_the_title_stays_a_heading(folder):
|
|
295
|
+
folder.write("a.md", "---\ntitle: Front\n---\n# Intro\ntext\n## Setup\nx")
|
|
296
|
+
item = folder.fetch("a.md")["a.md"]
|
|
297
|
+
assert item["title"] == "Front"
|
|
298
|
+
assert [(s["heading"], s["text"]) for s in item["sections"]] == [("Intro", "text"), ("Intro › Setup", "x")]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@pytest.mark.skipif(os.geteuid() == 0, reason="root can read anything")
|
|
302
|
+
def test_an_unreadable_folder_is_reported_not_silently_dropped(folder):
|
|
303
|
+
folder.write("top.md", "T")
|
|
304
|
+
folder.write("locked/a.md", "A")
|
|
305
|
+
(folder.root / "locked").chmod(0)
|
|
306
|
+
try:
|
|
307
|
+
code, records, err = folder.call("list")
|
|
308
|
+
finally:
|
|
309
|
+
(folder.root / "locked").chmod(0o755)
|
|
310
|
+
assert code == 0 and [r["id"] for r in records] == ["top.md"] and "locked" in err
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_a_wiki_link_with_a_folder_picks_the_right_one_of_two_same_named_notes(folder):
|
|
314
|
+
folder.write("a/Note.md", "A")
|
|
315
|
+
folder.write("b/Note.md", "B")
|
|
316
|
+
folder.write("n.md", "[[a/Note]] and [[b/Note|the other]] and [[Note]]")
|
|
317
|
+
assert folder.fetch("n.md")["n.md"]["links"] == ["a/Note.md", "b/Note.md"]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_a_link_target_in_angle_brackets_may_contain_spaces(folder):
|
|
321
|
+
folder.write("my note.md", "M")
|
|
322
|
+
folder.write("n.md", "[x](<my note.md>) and [y](<my note.md> \"title\")")
|
|
323
|
+
assert folder.fetch("n.md")["n.md"]["links"] == ["my note.md"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_an_escaped_pipe_in_a_wiki_link_inside_a_table(folder):
|
|
327
|
+
folder.write("Setup.md", "S")
|
|
328
|
+
folder.write("n.md", "| a |\n|---|\n| [[Setup\\|the guide]] |")
|
|
329
|
+
assert folder.fetch("n.md")["n.md"]["links"] == ["Setup.md"]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_the_yaml_document_end_marker_closes_the_front_matter(folder):
|
|
333
|
+
folder.write("a.md", "---\ntitle: T\n...\nBody")
|
|
334
|
+
item = folder.fetch("a.md")["a.md"]
|
|
335
|
+
assert item["title"] == "T" and item["sections"][0]["text"] == "Body"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_an_h1_equal_to_the_front_matter_title_is_not_repeated_in_the_headings(folder):
|
|
339
|
+
folder.write("a.md", "---\ntitle: Rotating Certificates\n---\n# rotating certificates\nIntro.\n\n## Steps\nx")
|
|
340
|
+
item = folder.fetch("a.md")["a.md"]
|
|
341
|
+
assert [(s["heading"], s["text"]) for s in item["sections"]] == [(None, "Intro."), ("Steps", "x")]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import jsonschema
|
|
9
|
+
|
|
10
|
+
from passageway.protocol import Connector
|
|
11
|
+
from passageway.testing.contract import check_connector
|
|
12
|
+
|
|
13
|
+
README = Path(__file__).resolve().parents[1] / "README.md"
|
|
14
|
+
SCHEMA = Path(__file__).resolve().parents[3] / "docs" / "reference" / "connector-protocol.schema.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def notes(root):
|
|
18
|
+
(root / "guides").mkdir()
|
|
19
|
+
(root / "a.md").write_text("---\ntitle: Alpha\n---\n# Alpha\nAbout lighthouses.\n\n## Care\nPaint them. See [b](guides/b.md).")
|
|
20
|
+
(root / "guides" / "b.md").write_text("# Beta\nAbout harbours.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def command():
|
|
24
|
+
return [sys.executable, "-m", "passageway_connector_markdown"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_the_connector_passes_the_shared_contract_as_a_real_subprocess(tmp_path):
|
|
28
|
+
notes(tmp_path)
|
|
29
|
+
connector = Connector("markdown", command(), env={"FOLDER": str(tmp_path)})
|
|
30
|
+
check_connector(connector)
|
|
31
|
+
items = {i.id: i for i in connector.fetch(["a.md", "guides/b.md"])}
|
|
32
|
+
assert items["a.md"].links == ("guides/b.md",) and items["a.md"].title == "Alpha"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_non_ascii_survives_a_subprocess_whose_stdout_is_ascii(tmp_path):
|
|
36
|
+
(tmp_path / "a.md").write_text("# Köln Süd\n\nGröße und Maße.", encoding="utf-8")
|
|
37
|
+
connector = Connector("markdown", command(), env={"FOLDER": str(tmp_path), "PYTHONIOENCODING": "ascii"})
|
|
38
|
+
item = connector.fetch(["a.md"])[0]
|
|
39
|
+
assert item.title == "Köln Süd" and item.sections[0].text == "Größe und Maße."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_the_output_validates_against_the_published_json_schema(tmp_path):
|
|
43
|
+
notes(tmp_path)
|
|
44
|
+
schema = json.loads(SCHEMA.read_text())
|
|
45
|
+
|
|
46
|
+
def valid(name, record):
|
|
47
|
+
return jsonschema.Draft202012Validator({"$ref": f"#/$defs/{name}", "$defs": schema["$defs"]}).is_valid(record)
|
|
48
|
+
|
|
49
|
+
def lines(mode, stdin=""):
|
|
50
|
+
out = subprocess.run(command() + [mode], env={**os.environ, "FOLDER": str(tmp_path)}, input=stdin,
|
|
51
|
+
capture_output=True, text=True, check=True).stdout
|
|
52
|
+
return [json.loads(line) for line in out.splitlines()]
|
|
53
|
+
|
|
54
|
+
assert lines("check") and all(valid("check", r) for r in lines("check"))
|
|
55
|
+
assert lines("list") and all(valid("listRecord", r) for r in lines("list"))
|
|
56
|
+
items = lines("fetch", '{"id": "a.md"}\n{"id": "guides/b.md"}\n')
|
|
57
|
+
assert len(items) == 2 and all(valid("item", r) for r in items)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_every_setting_is_in_the_readme():
|
|
61
|
+
code = (Path(__file__).resolve().parents[1] / "passageway_connector_markdown" / "connector.py").read_text()
|
|
62
|
+
names = sorted(set(re.findall(r'(?:env\.get|names|whole)\(\s*(?:env,\s*)?"([A-Z_]+)"', code)))
|
|
63
|
+
assert len(names) >= 4, names # the pattern must actually find the settings
|
|
64
|
+
readme = README.read_text()
|
|
65
|
+
for name in names:
|
|
66
|
+
assert f"`{name}`" in readme, f"{name} is read by the markdown connector but missing from its README"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_the_example_in_the_readme_is_what_the_connector_really_produces(tmp_path):
|
|
70
|
+
readme = README.read_text()
|
|
71
|
+
note = re.search(r"```markdown\n(.*?)```", readme, re.S).group(1)
|
|
72
|
+
shown = json.loads(re.search(r"```json\n(.*?)```", readme, re.S).group(1))
|
|
73
|
+
(tmp_path / "ops").mkdir()
|
|
74
|
+
(tmp_path / "ops" / "rotating-certificates.md").write_text(note)
|
|
75
|
+
(tmp_path / "ops" / "deploy.md").write_text("# Deploy\nHow to deploy.")
|
|
76
|
+
out = subprocess.run(command() + ["fetch"], env={**os.environ, "FOLDER": str(tmp_path)},
|
|
77
|
+
input='{"id": "ops/rotating-certificates.md"}\n', capture_output=True, text=True, check=True).stdout
|
|
78
|
+
real = json.loads(out)
|
|
79
|
+
for key, value in shown.items():
|
|
80
|
+
assert real[key] == value, f"the README shows {key} = {value!r} but the connector prints {real[key]!r}"
|