houdinimd-docs 0.1.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.
- houdinimd_docs-0.1.0/PKG-INFO +45 -0
- houdinimd_docs-0.1.0/README.md +30 -0
- houdinimd_docs-0.1.0/engine/Cargo.toml +18 -0
- houdinimd_docs-0.1.0/engine/src/assets.rs +276 -0
- houdinimd_docs-0.1.0/engine/src/db.rs +137 -0
- houdinimd_docs-0.1.0/engine/src/examples.rs +66 -0
- houdinimd_docs-0.1.0/engine/src/family.rs +118 -0
- houdinimd_docs-0.1.0/engine/src/help.rs +225 -0
- houdinimd_docs-0.1.0/engine/src/index.rs +358 -0
- houdinimd_docs-0.1.0/engine/src/inherit.rs +66 -0
- houdinimd_docs-0.1.0/engine/src/install.rs +445 -0
- houdinimd_docs-0.1.0/engine/src/lib.rs +30 -0
- houdinimd_docs-0.1.0/engine/src/listing.rs +320 -0
- houdinimd_docs-0.1.0/engine/src/packages.rs +237 -0
- houdinimd_docs-0.1.0/engine/src/page.rs +103 -0
- houdinimd_docs-0.1.0/engine/src/search.rs +220 -0
- houdinimd_docs-0.1.0/engine/src/sections.rs +209 -0
- houdinimd_docs-0.1.0/pydocs/.gitignore +2 -0
- houdinimd_docs-0.1.0/pydocs/Cargo.lock +654 -0
- houdinimd_docs-0.1.0/pydocs/Cargo.toml +28 -0
- houdinimd_docs-0.1.0/pydocs/README.md +30 -0
- houdinimd_docs-0.1.0/pydocs/src/lib.rs +144 -0
- houdinimd_docs-0.1.0/pyproject.toml +26 -0
- houdinimd_docs-0.1.0/wiki/Cargo.toml +12 -0
- houdinimd_docs-0.1.0/wiki/src/blocks.rs +1177 -0
- houdinimd_docs-0.1.0/wiki/src/html.rs +330 -0
- houdinimd_docs-0.1.0/wiki/src/include.rs +304 -0
- houdinimd_docs-0.1.0/wiki/src/inline.rs +426 -0
- houdinimd_docs-0.1.0/wiki/src/lib.rs +198 -0
- houdinimd_docs-0.1.0/wiki/src/markdown.rs +754 -0
- houdinimd_docs-0.1.0/wiki/src/model.rs +293 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: houdinimd-docs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
7
|
+
Summary: Houdini's own documentation, read out of the install on this machine.
|
|
8
|
+
Keywords: houdini,documentation,sidefx
|
|
9
|
+
Author: JTCHE
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
13
|
+
Project-URL: Homepage, https://houdinimd.com
|
|
14
|
+
|
|
15
|
+
# houdinimd-docs
|
|
16
|
+
|
|
17
|
+
Houdini's own documentation, read out of the Houdini install on this machine.
|
|
18
|
+
It is the documentation engine of the [HoudiniMD](https://houdinimd.com)
|
|
19
|
+
desktop app, compiled from Rust into a Python module. The
|
|
20
|
+
[HoudiniMCP](https://github.com/JTCHE/houdini-mcp) bridge uses it.
|
|
21
|
+
|
|
22
|
+
No documentation ships in this package. Every page comes from the help files
|
|
23
|
+
of an installed Houdini, so the text matches that build exactly.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import houdinimd_docs
|
|
27
|
+
|
|
28
|
+
docs = houdinimd_docs.Docs("/path/to/a/folder/for/the/index")
|
|
29
|
+
docs.installs() # the Houdini builds found, newest first
|
|
30
|
+
docs.page("nodes/sop/copytopoints") # one page, as Markdown
|
|
31
|
+
docs.search("copy to points", limit=5) # ranked full-text search
|
|
32
|
+
docs.page("hom/hou/Node", build="21.0.829")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Every call returns a JSON string. `page` raises `ValueError` when the build
|
|
36
|
+
has no such page.
|
|
37
|
+
|
|
38
|
+
The first search on a build indexes it into a SQLite file in the folder you
|
|
39
|
+
name, which takes a few seconds. The index stays there, so this happens once
|
|
40
|
+
per build. A page read never waits for it.
|
|
41
|
+
|
|
42
|
+
Without `build`, the reader uses the Houdini in `$HFS`, then the newest build
|
|
43
|
+
it finds. The build comes from the install's own `SYS_Version.h`, never from
|
|
44
|
+
its folder name.
|
|
45
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# houdinimd-docs
|
|
2
|
+
|
|
3
|
+
Houdini's own documentation, read out of the Houdini install on this machine.
|
|
4
|
+
It is the documentation engine of the [HoudiniMD](https://houdinimd.com)
|
|
5
|
+
desktop app, compiled from Rust into a Python module. The
|
|
6
|
+
[HoudiniMCP](https://github.com/JTCHE/houdini-mcp) bridge uses it.
|
|
7
|
+
|
|
8
|
+
No documentation ships in this package. Every page comes from the help files
|
|
9
|
+
of an installed Houdini, so the text matches that build exactly.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import houdinimd_docs
|
|
13
|
+
|
|
14
|
+
docs = houdinimd_docs.Docs("/path/to/a/folder/for/the/index")
|
|
15
|
+
docs.installs() # the Houdini builds found, newest first
|
|
16
|
+
docs.page("nodes/sop/copytopoints") # one page, as Markdown
|
|
17
|
+
docs.search("copy to points", limit=5) # ranked full-text search
|
|
18
|
+
docs.page("hom/hou/Node", build="21.0.829")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Every call returns a JSON string. `page` raises `ValueError` when the build
|
|
22
|
+
has no such page.
|
|
23
|
+
|
|
24
|
+
The first search on a build indexes it into a SQLite file in the folder you
|
|
25
|
+
name, which takes a few seconds. The index stays there, so this happens once
|
|
26
|
+
per build. A page read never waits for it.
|
|
27
|
+
|
|
28
|
+
Without `build`, the reader uses the Houdini in `$HFS`, then the newest build
|
|
29
|
+
it finds. The build comes from the install's own `SYS_Version.h`, never from
|
|
30
|
+
its folder name.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "engine"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
rust-version = "1.85"
|
|
6
|
+
description = "Reads Houdini help out of an install: pages, index and search."
|
|
7
|
+
|
|
8
|
+
[dependencies]
|
|
9
|
+
serde = { version = "1", features = ["derive"] }
|
|
10
|
+
serde_json = "1"
|
|
11
|
+
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
|
12
|
+
wiki = { version = "0.1.0", path = "../wiki" }
|
|
13
|
+
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
|
14
|
+
rayon = "1.12.0"
|
|
15
|
+
|
|
16
|
+
[target."cfg(windows)".dependencies]
|
|
17
|
+
windows-sys = { version = "0.61.2", features = ["Win32_System_Threading"] }
|
|
18
|
+
winreg = "0.56.0"
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
//! Turns a reference written beside a help page into the path the app reads:
|
|
2
|
+
//! a picture or video for the `himage` protocol, and a link for the router.
|
|
3
|
+
//!
|
|
4
|
+
//! A page writes its assets the way the SideFX help server serves them, which
|
|
5
|
+
//! is not the way the install stores them:
|
|
6
|
+
//!
|
|
7
|
+
//! - `/images/playbar/timeline.png` is `playbar/timeline.png` in `images.zip`.
|
|
8
|
+
//! - `../images/BasisSOP.jpg` on `nodes/sop/basis` is `nodes/BasisSOP.jpg` in
|
|
9
|
+
//! the same zip. The `images` segment is a serving path, not a folder.
|
|
10
|
+
//! - `/videos/tween.webm` is a loose file under `$HFS/houdini/help/videos`.
|
|
11
|
+
//! - `/movies/rotate.gif` is the same shape as `videos`, under the name a
|
|
12
|
+
//! package's own help folder uses for it — SideFX Labs ships no `videos/`.
|
|
13
|
+
//!
|
|
14
|
+
//! All three come back as one shape, `images/…`, `videos/…` or `movies/…`, so
|
|
15
|
+
//! the protocol handler has one thing to read and the front-end has nothing
|
|
16
|
+
//! to know.
|
|
17
|
+
|
|
18
|
+
use wiki::{Block, Inline, LinkTarget};
|
|
19
|
+
|
|
20
|
+
/// The asset path for `src` as written on the page at `page`, or `None` when
|
|
21
|
+
/// the reference names nothing this app can read.
|
|
22
|
+
pub fn resolve(page: &str, src: &str) -> Option<String> {
|
|
23
|
+
// `opdef:` and `./MainImage.jpg` name a picture inside an HDA, which is
|
|
24
|
+
// not a file in the install. Only the pages about writing help use them.
|
|
25
|
+
if src.starts_with("opdef:") || src.contains('?') || src.is_empty() {
|
|
26
|
+
return None;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let mut parts: Vec<&str> = Vec::new();
|
|
30
|
+
if !src.starts_with('/') {
|
|
31
|
+
// A relative reference stands beside the page, so the page's own name
|
|
32
|
+
// is not part of the base.
|
|
33
|
+
let dir = page
|
|
34
|
+
.trim_matches('/')
|
|
35
|
+
.rsplit_once('/')
|
|
36
|
+
.map_or("", |(d, _)| d);
|
|
37
|
+
parts.extend(dir.split('/'));
|
|
38
|
+
}
|
|
39
|
+
parts.extend(src.split('/'));
|
|
40
|
+
|
|
41
|
+
let mut path: Vec<&str> = Vec::new();
|
|
42
|
+
for part in parts {
|
|
43
|
+
match part {
|
|
44
|
+
"" | "." => {}
|
|
45
|
+
".." => {
|
|
46
|
+
path.pop()?;
|
|
47
|
+
}
|
|
48
|
+
part => path.push(part),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The first `images`, `videos` or `movies` segment says which store holds
|
|
53
|
+
// the file. Everything before it is the section the page lives in, which
|
|
54
|
+
// the store keeps as its own top folder.
|
|
55
|
+
let at = path.iter().position(|p| *p == "images" || *p == "videos" || *p == "movies")?;
|
|
56
|
+
let store = path.remove(at);
|
|
57
|
+
if path.len() <= at {
|
|
58
|
+
return None;
|
|
59
|
+
}
|
|
60
|
+
Some(format!("{store}/{}", path.join("/")))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// The app path for a wiki link written beside the page rather than from the
|
|
64
|
+
/// help root. `news/22/index` writes `[Solaris|solaris]`, and that means
|
|
65
|
+
/// `/news/22/solaris`, not a page under the index.
|
|
66
|
+
///
|
|
67
|
+
/// ponytail: the base is the folder the page name sits in, so a page read at
|
|
68
|
+
/// its folder form (`news/22` instead of `news/22/index`) bases one level too
|
|
69
|
+
/// high. The index writes the `/index` form for every folder page, so only a
|
|
70
|
+
/// hand-written address reaches the other one.
|
|
71
|
+
pub fn link(page: &str, target: &str) -> Option<String> {
|
|
72
|
+
if target.is_empty() || target.starts_with('/') {
|
|
73
|
+
return None;
|
|
74
|
+
}
|
|
75
|
+
let dir = page
|
|
76
|
+
.trim_matches('/')
|
|
77
|
+
.rsplit_once('/')
|
|
78
|
+
.map_or("", |(dir, _)| dir);
|
|
79
|
+
|
|
80
|
+
let mut path: Vec<&str> = Vec::new();
|
|
81
|
+
for part in dir.split('/').chain(target.split('/')) {
|
|
82
|
+
match part {
|
|
83
|
+
"" | "." => {}
|
|
84
|
+
".." => {
|
|
85
|
+
path.pop()?;
|
|
86
|
+
}
|
|
87
|
+
part => path.push(part),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// A single segment is a section, not a page, and nothing links to one.
|
|
91
|
+
if path.len() < 2 {
|
|
92
|
+
return None;
|
|
93
|
+
}
|
|
94
|
+
Some(format!("/{}", path.join("/")))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Rewrites every asset reference in a page to the path the `himage` protocol
|
|
98
|
+
/// reads. A reference that names nothing readable is dropped, so the reader
|
|
99
|
+
/// gets the text without a broken frame in the middle of it.
|
|
100
|
+
pub fn rewrite(page: &str, blocks: &mut [Block], name_of: &dyn Fn(&str) -> Option<String>) {
|
|
101
|
+
for block in blocks {
|
|
102
|
+
match block {
|
|
103
|
+
Block::Item {
|
|
104
|
+
name,
|
|
105
|
+
label,
|
|
106
|
+
props,
|
|
107
|
+
children,
|
|
108
|
+
} => {
|
|
109
|
+
if name == "video" {
|
|
110
|
+
for (key, value) in props.iter_mut() {
|
|
111
|
+
if key == "src" {
|
|
112
|
+
*value = resolve(page, value).unwrap_or_default();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
inlines(page, label, name_of);
|
|
117
|
+
rewrite(page, children, name_of);
|
|
118
|
+
}
|
|
119
|
+
Block::Heading {
|
|
120
|
+
title, children, ..
|
|
121
|
+
} => {
|
|
122
|
+
inlines(page, &mut title.main, name_of);
|
|
123
|
+
rewrite(page, children, name_of);
|
|
124
|
+
}
|
|
125
|
+
Block::Section { children, .. } => rewrite(page, children, name_of),
|
|
126
|
+
Block::Definition { term, children, .. } => {
|
|
127
|
+
inlines(page, term, name_of);
|
|
128
|
+
rewrite(page, children, name_of);
|
|
129
|
+
}
|
|
130
|
+
Block::Usage { children, .. } => rewrite(page, children, name_of),
|
|
131
|
+
Block::Paragraph { text } | Block::Summary { text } => inlines(page, text, name_of),
|
|
132
|
+
Block::Subtopic { link, children } => {
|
|
133
|
+
inlines(page, link, name_of);
|
|
134
|
+
rewrite(page, children, name_of);
|
|
135
|
+
}
|
|
136
|
+
Block::Bullets { items } | Block::Numbers { items } => {
|
|
137
|
+
for item in items {
|
|
138
|
+
rewrite(page, &mut item.blocks, name_of);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
Block::Table { rows } => {
|
|
142
|
+
for row in rows {
|
|
143
|
+
for cell in row {
|
|
144
|
+
rewrite(page, &mut cell.blocks, name_of);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
Block::Html { children, .. } => rewrite(page, children, name_of),
|
|
149
|
+
Block::Divider { children, .. } => rewrite(page, children, name_of),
|
|
150
|
+
Block::Code { .. }
|
|
151
|
+
| Block::Include { .. }
|
|
152
|
+
| Block::RawHtml { .. } => {}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn inlines(page: &str, inlines: &mut Vec<Inline>, name_of: &dyn Fn(&str) -> Option<String>) {
|
|
158
|
+
inlines.retain_mut(|inline| match inline {
|
|
159
|
+
Inline::Image { src } => match resolve(page, src) {
|
|
160
|
+
Some(path) => {
|
|
161
|
+
*src = path;
|
|
162
|
+
true
|
|
163
|
+
}
|
|
164
|
+
None => false,
|
|
165
|
+
},
|
|
166
|
+
Inline::Bold { body } | Inline::Italic { body } | Inline::Ui { body } => {
|
|
167
|
+
self::inlines(page, body, name_of);
|
|
168
|
+
true
|
|
169
|
+
}
|
|
170
|
+
Inline::Link { text, target } => {
|
|
171
|
+
if let LinkTarget::Wiki { path, .. } = target {
|
|
172
|
+
// `[intro]` names a page and shows the address; SideFX shows
|
|
173
|
+
// the page's title there instead.
|
|
174
|
+
let bare = matches!(text.as_slice(), [Inline::Text { text }] if text == path);
|
|
175
|
+
if let Some(resolved) = link(page, path) {
|
|
176
|
+
*path = resolved;
|
|
177
|
+
}
|
|
178
|
+
if bare && let Some(found) = name_of(path) {
|
|
179
|
+
*text = vec![Inline::Text { text: found }];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
self::inlines(page, text, name_of);
|
|
183
|
+
true
|
|
184
|
+
}
|
|
185
|
+
_ => true,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[cfg(test)]
|
|
190
|
+
mod tests {
|
|
191
|
+
use super::{link, resolve};
|
|
192
|
+
|
|
193
|
+
#[test]
|
|
194
|
+
fn a_relative_link_stands_beside_its_page() {
|
|
195
|
+
assert_eq!(
|
|
196
|
+
link("news/22/index", "solaris").as_deref(),
|
|
197
|
+
Some("/news/22/solaris")
|
|
198
|
+
);
|
|
199
|
+
assert_eq!(
|
|
200
|
+
link("news/22/karma", "solaris").as_deref(),
|
|
201
|
+
Some("/news/22/solaris")
|
|
202
|
+
);
|
|
203
|
+
assert_eq!(
|
|
204
|
+
link("nodes/sop/box", "../../vex/functions/lerp").as_deref(),
|
|
205
|
+
Some("/vex/functions/lerp")
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#[test]
|
|
210
|
+
fn an_absolute_link_is_left_alone() {
|
|
211
|
+
assert_eq!(link("news/22/index", "/nodes/sop/box"), None);
|
|
212
|
+
assert_eq!(link("news/22/index", ""), None);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[test]
|
|
216
|
+
fn a_link_that_leaves_the_help_root_is_refused() {
|
|
217
|
+
assert_eq!(link("news/22/index", "../../../elsewhere"), None);
|
|
218
|
+
assert_eq!(link("news/index", "solaris").as_deref(), Some("/news/solaris"));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[test]
|
|
222
|
+
fn absolute_image_drops_the_serving_folder() {
|
|
223
|
+
assert_eq!(
|
|
224
|
+
resolve("basics/playbar", "/images/playbar/timeline.png").as_deref(),
|
|
225
|
+
Some("images/playbar/timeline.png")
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#[test]
|
|
230
|
+
fn a_relative_image_keeps_the_section_it_came_from() {
|
|
231
|
+
assert_eq!(
|
|
232
|
+
resolve("nodes/sop/basis", "../images/BasisSOP.jpg").as_deref(),
|
|
233
|
+
Some("images/nodes/BasisSOP.jpg")
|
|
234
|
+
);
|
|
235
|
+
assert_eq!(
|
|
236
|
+
resolve("nodes/cop2/rotoshape", "../images/RotoShapeEditMode.jpg").as_deref(),
|
|
237
|
+
Some("images/nodes/RotoShapeEditMode.jpg")
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[test]
|
|
242
|
+
fn an_empty_segment_is_not_a_folder() {
|
|
243
|
+
assert_eq!(
|
|
244
|
+
resolve(
|
|
245
|
+
"nodes/lop/rendergeometrysettings",
|
|
246
|
+
"//images/solaris/kug/a.jpg"
|
|
247
|
+
)
|
|
248
|
+
.as_deref(),
|
|
249
|
+
Some("images/solaris/kug/a.jpg")
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn a_video_reads_from_the_videos_folder() {
|
|
255
|
+
assert_eq!(
|
|
256
|
+
resolve("anim/animtoolbar", "/videos/animtoolbar_tween.webm").as_deref(),
|
|
257
|
+
Some("videos/animtoolbar_tween.webm")
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn a_picture_inside_an_asset_is_not_a_file() {
|
|
263
|
+
assert_eq!(resolve("help/nodes", "opdef:.?test.png"), None);
|
|
264
|
+
assert_eq!(
|
|
265
|
+
resolve("help/nodes", "opdef:matt::Sop/e::1.0?test.png"),
|
|
266
|
+
None
|
|
267
|
+
);
|
|
268
|
+
assert_eq!(resolve("help/nodes", "./MainImage.jpg"), None);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#[test]
|
|
272
|
+
fn a_reference_that_leaves_the_help_root_is_refused() {
|
|
273
|
+
assert_eq!(resolve("basics/playbar", "../../../images/x.png"), None);
|
|
274
|
+
assert_eq!(resolve("basics/playbar", "/images"), None);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
//! `index.db`: what the engine derives from a Houdini install.
|
|
2
|
+
//!
|
|
3
|
+
//! Everything here is derived. Delete the file and nothing of the reader's is
|
|
4
|
+
//! lost — the background pass fills it again in seconds. The reader's own work
|
|
5
|
+
//! lives in `user.db`, which the app owns and this module never touches.
|
|
6
|
+
//!
|
|
7
|
+
//! See spec: Local — SQLite FTS5 Index.
|
|
8
|
+
|
|
9
|
+
use std::path::{Path, PathBuf};
|
|
10
|
+
|
|
11
|
+
use rusqlite::Connection;
|
|
12
|
+
|
|
13
|
+
/// `index.db` sits in the folder the caller names.
|
|
14
|
+
pub fn path(data: &Path) -> PathBuf {
|
|
15
|
+
data.join("index.db")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Opens `index.db` and makes its schema.
|
|
19
|
+
pub fn open(data: &Path) -> Result<Connection, String> {
|
|
20
|
+
std::fs::create_dir_all(data).map_err(|e| format!("{}: {e}", data.display()))?;
|
|
21
|
+
let index = path(data);
|
|
22
|
+
let db = Connection::open(&index).map_err(|e| format!("{}: {e}", index.display()))?;
|
|
23
|
+
|
|
24
|
+
// WAL lets the background indexer write while the reader reads.
|
|
25
|
+
db.pragma_update(None, "journal_mode", "WAL")
|
|
26
|
+
.map_err(|e| e.to_string())?;
|
|
27
|
+
db.pragma_update(None, "synchronous", "NORMAL")
|
|
28
|
+
.map_err(|e| e.to_string())?;
|
|
29
|
+
|
|
30
|
+
reset_if_stale(&db)?;
|
|
31
|
+
db.execute_batch(SCHEMA).map_err(|e| e.to_string())?;
|
|
32
|
+
Ok(db)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// What `SCHEMA` describes. Raise it whenever the derived tables change shape,
|
|
36
|
+
/// or the parser writes different rows into them.
|
|
37
|
+
const VERSION: u32 = 3;
|
|
38
|
+
|
|
39
|
+
/// Throws away everything derived from the Houdini install when the shape it
|
|
40
|
+
/// was written in is not the shape this build reads. `index.db` is derived, so
|
|
41
|
+
/// there is nothing here to migrate — the background pass fills it again in
|
|
42
|
+
/// seconds.
|
|
43
|
+
fn reset_if_stale(db: &Connection) -> Result<(), String> {
|
|
44
|
+
let found: u32 = db
|
|
45
|
+
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
46
|
+
.map_err(|e| e.to_string())?;
|
|
47
|
+
if found != VERSION {
|
|
48
|
+
db.execute_batch(
|
|
49
|
+
"DROP TABLE IF EXISTS pages;
|
|
50
|
+
DROP TABLE IF EXISTS pages_fts;
|
|
51
|
+
DROP TABLE IF EXISTS builds;",
|
|
52
|
+
)
|
|
53
|
+
.map_err(|e| e.to_string())?;
|
|
54
|
+
db.pragma_update(None, "user_version", VERSION)
|
|
55
|
+
.map_err(|e| e.to_string())?;
|
|
56
|
+
}
|
|
57
|
+
Ok(())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// One build is one set of rows, never one file and never one folder. A
|
|
61
|
+
/// Houdini upgrade rewrites its own rows and leaves the others alone.
|
|
62
|
+
const SCHEMA: &str = r#"
|
|
63
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
64
|
+
build TEXT PRIMARY KEY,
|
|
65
|
+
pages INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
-- 0 while the background pass is still filling this build in.
|
|
67
|
+
done INTEGER NOT NULL DEFAULT 0
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
71
|
+
build TEXT NOT NULL,
|
|
72
|
+
path TEXT NOT NULL,
|
|
73
|
+
title TEXT NOT NULL,
|
|
74
|
+
node_type TEXT,
|
|
75
|
+
icon TEXT,
|
|
76
|
+
summary TEXT,
|
|
77
|
+
PRIMARY KEY (build, path)
|
|
78
|
+
) WITHOUT ROWID;
|
|
79
|
+
|
|
80
|
+
-- One row per SECTION of a page, not per page: a hit names the heading the
|
|
81
|
+
-- reader should land on, which is what the result list draws under the page.
|
|
82
|
+
-- Cutting the body at its headings stores it once, not twice — see
|
|
83
|
+
-- `sections.rs`.
|
|
84
|
+
--
|
|
85
|
+
-- `heading` is empty and `title` is set on the row for the text above the first
|
|
86
|
+
-- heading, so a page is named exactly once and the title weight cannot multiply
|
|
87
|
+
-- with the number of sections it has.
|
|
88
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
|
|
89
|
+
build UNINDEXED,
|
|
90
|
+
path UNINDEXED,
|
|
91
|
+
slug UNINDEXED,
|
|
92
|
+
heading,
|
|
93
|
+
title,
|
|
94
|
+
body,
|
|
95
|
+
tokenize = "unicode61 remove_diacritics 2"
|
|
96
|
+
);
|
|
97
|
+
"#;
|
|
98
|
+
|
|
99
|
+
/// Turns what the reader typed into an FTS5 query.
|
|
100
|
+
///
|
|
101
|
+
/// Every token is quoted, so a bare `*`, `-` or `NEAR` is text and not syntax.
|
|
102
|
+
/// The last token takes a prefix star, because the reader is still typing it.
|
|
103
|
+
pub fn match_query(text: &str) -> Option<String> {
|
|
104
|
+
let tokens: Vec<String> = text
|
|
105
|
+
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
|
106
|
+
.filter(|t| !t.is_empty())
|
|
107
|
+
.map(|t| format!("\"{t}\""))
|
|
108
|
+
.collect();
|
|
109
|
+
let (last, rest) = tokens.split_last()?;
|
|
110
|
+
let mut query = rest.join(" ");
|
|
111
|
+
if !query.is_empty() {
|
|
112
|
+
query.push(' ');
|
|
113
|
+
}
|
|
114
|
+
query.push_str(last);
|
|
115
|
+
query.push('*');
|
|
116
|
+
Some(query)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[cfg(test)]
|
|
120
|
+
mod tests {
|
|
121
|
+
use super::match_query;
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn a_query_quotes_every_token_and_extends_the_last() {
|
|
125
|
+
assert_eq!(match_query("copy to points").unwrap(), "\"copy\" \"to\" \"points\"*");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[test]
|
|
129
|
+
fn syntax_the_reader_types_stays_text() {
|
|
130
|
+
assert_eq!(match_query("a OR b*").unwrap(), "\"a\" \"OR\" \"b\"*");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn nothing_to_match_is_no_query() {
|
|
135
|
+
assert!(match_query(" ").is_none());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//! The example files that ship for a node, as a section at the foot of its
|
|
2
|
+
//! page.
|
|
3
|
+
//!
|
|
4
|
+
//! An example is a scene file plus a page beside it, under
|
|
5
|
+
//! `help/examples/<node path>/`. The node page itself never names them —
|
|
6
|
+
//! SideFX's doc build finds them by their place on the disk and writes the
|
|
7
|
+
//! Examples section from what it finds. See spec: Local — Whole sections are
|
|
8
|
+
//! dropped from pages.
|
|
9
|
+
//!
|
|
10
|
+
//! Not in the `wiki` crate: the crate reads no file, and this reads a
|
|
11
|
+
//! directory. Same reason as `family.rs`. See `agents/architecture.md`.
|
|
12
|
+
|
|
13
|
+
use std::path::Path;
|
|
14
|
+
|
|
15
|
+
use wiki::model::ListItem;
|
|
16
|
+
use wiki::{Block, Inline, LinkTarget};
|
|
17
|
+
|
|
18
|
+
/// Appends an Examples section when the install ships examples for this page.
|
|
19
|
+
///
|
|
20
|
+
/// `path` is the page's own help path, `nodes/sop/file`. The examples for it
|
|
21
|
+
/// sit under `examples/` at the same path.
|
|
22
|
+
pub fn append(help: &Path, path: &str, blocks: &mut Vec<Block>) {
|
|
23
|
+
if path.starts_with("examples/") {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
let mut dir = help.join("examples");
|
|
27
|
+
for part in path.split('/') {
|
|
28
|
+
dir.push(part);
|
|
29
|
+
}
|
|
30
|
+
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
31
|
+
return;
|
|
32
|
+
};
|
|
33
|
+
let mut names: Vec<String> = entries
|
|
34
|
+
.flatten()
|
|
35
|
+
.filter_map(|entry| {
|
|
36
|
+
let file = entry.file_name().into_string().ok()?;
|
|
37
|
+
file.strip_suffix(".txt").map(str::to_string)
|
|
38
|
+
})
|
|
39
|
+
.collect();
|
|
40
|
+
if names.is_empty() {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
names.sort_unstable();
|
|
44
|
+
|
|
45
|
+
let items = names
|
|
46
|
+
.into_iter()
|
|
47
|
+
.map(|name| ListItem {
|
|
48
|
+
blocks: vec![Block::Paragraph {
|
|
49
|
+
text: vec![Inline::Link {
|
|
50
|
+
text: vec![Inline::Text { text: name.clone() }],
|
|
51
|
+
target: LinkTarget::Wiki {
|
|
52
|
+
path: format!("/examples/{path}/{name}"),
|
|
53
|
+
anchor: None,
|
|
54
|
+
},
|
|
55
|
+
}],
|
|
56
|
+
}],
|
|
57
|
+
props: Vec::new(),
|
|
58
|
+
})
|
|
59
|
+
.collect();
|
|
60
|
+
blocks.push(Block::Section {
|
|
61
|
+
name: "examples".to_string(),
|
|
62
|
+
title: None,
|
|
63
|
+
props: Vec::new(),
|
|
64
|
+
children: vec![Block::Bullets { items }],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//! The sibling list a VEX function or statement page carries at its foot:
|
|
2
|
+
//! every other page that shares one of its `#tags`, one heading per tag.
|
|
3
|
+
//!
|
|
4
|
+
//! SideFX's own doc build writes this from a tag index that sits outside the
|
|
5
|
+
//! page source, so the raw wiki markup never carries it — the app rebuilds it
|
|
6
|
+
//! from its own reading of `vex.zip` instead. See spec: Local — VEX pages lose
|
|
7
|
+
//! the family index.
|
|
8
|
+
//!
|
|
9
|
+
//! Not in the `wiki` crate: the crate reads no file, and this reads every VEX
|
|
10
|
+
//! page once to answer one page. See `agents/architecture.md`.
|
|
11
|
+
|
|
12
|
+
use std::collections::HashMap;
|
|
13
|
+
use std::path::{Path, PathBuf};
|
|
14
|
+
use std::sync::{Arc, LazyLock, Mutex};
|
|
15
|
+
|
|
16
|
+
use wiki::model::ListItem;
|
|
17
|
+
use wiki::{Block, Inline, LinkTarget};
|
|
18
|
+
|
|
19
|
+
struct Entry {
|
|
20
|
+
name: String,
|
|
21
|
+
tags: Vec<String>,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// `vex.zip` read once per install and reused for every function page after
|
|
25
|
+
/// the first. About 1,100 small pages; parsing them again for every page open
|
|
26
|
+
/// would cost more than the page itself.
|
|
27
|
+
static CACHE: LazyLock<Mutex<HashMap<PathBuf, Arc<Vec<Entry>>>>> =
|
|
28
|
+
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
29
|
+
|
|
30
|
+
fn entries(help: &Path) -> Arc<Vec<Entry>> {
|
|
31
|
+
let zip = help.join("vex.zip");
|
|
32
|
+
let mut cache = CACHE.lock().expect("the family cache is not poisoned");
|
|
33
|
+
if let Some(found) = cache.get(&zip) {
|
|
34
|
+
return found.clone();
|
|
35
|
+
}
|
|
36
|
+
let built = Arc::new(read(&zip));
|
|
37
|
+
cache.insert(zip, built.clone());
|
|
38
|
+
built
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fn read(zip: &Path) -> Vec<Entry> {
|
|
42
|
+
let names = crate::help::entries(zip, "functions/")
|
|
43
|
+
.into_iter()
|
|
44
|
+
.chain(crate::help::entries(zip, "statements/"));
|
|
45
|
+
let mut out = Vec::new();
|
|
46
|
+
for name in names {
|
|
47
|
+
let stem = name
|
|
48
|
+
.rsplit('/')
|
|
49
|
+
.next()
|
|
50
|
+
.unwrap_or(&name)
|
|
51
|
+
.trim_end_matches(".txt");
|
|
52
|
+
// A page written to be included by another, not a function of its own.
|
|
53
|
+
if stem.starts_with('_') {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
let Some(source) = crate::help::text(zip, &name) else {
|
|
57
|
+
continue;
|
|
58
|
+
};
|
|
59
|
+
let (props, _) = wiki::blocks::parse_source(&source);
|
|
60
|
+
if !matches!(wiki::model::prop(&props, "type"), Some("vex" | "vexstatement")) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
let Some(tags) = wiki::model::prop(&props, "tags") else {
|
|
64
|
+
continue;
|
|
65
|
+
};
|
|
66
|
+
let tags = tags
|
|
67
|
+
.split(',')
|
|
68
|
+
.map(str::trim)
|
|
69
|
+
.filter(|t| !t.is_empty())
|
|
70
|
+
.map(str::to_string)
|
|
71
|
+
.collect();
|
|
72
|
+
out.push(Entry { name: stem.to_string(), tags });
|
|
73
|
+
}
|
|
74
|
+
out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Appends one `Block::Section` per tag the page carries, each an
|
|
78
|
+
/// alphabetical list of every VEX function or statement with that tag
|
|
79
|
+
/// (this page included), in the order the page's own `#tags` lists them.
|
|
80
|
+
pub fn append(help: &Path, props: &wiki::Props, blocks: &mut Vec<Block>) {
|
|
81
|
+
if !matches!(wiki::model::prop(props, "type"), Some("vex" | "vexstatement")) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
let Some(own_tags) = wiki::model::prop(props, "tags") else {
|
|
85
|
+
return;
|
|
86
|
+
};
|
|
87
|
+
let all = entries(help);
|
|
88
|
+
for tag in own_tags.split(',').map(str::trim).filter(|t| !t.is_empty()) {
|
|
89
|
+
let mut names: Vec<&str> = all
|
|
90
|
+
.iter()
|
|
91
|
+
.filter(|entry| entry.tags.iter().any(|t| t == tag))
|
|
92
|
+
.map(|entry| entry.name.as_str())
|
|
93
|
+
.collect();
|
|
94
|
+
names.sort_unstable();
|
|
95
|
+
names.dedup();
|
|
96
|
+
if names.is_empty() {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
let items = names
|
|
100
|
+
.into_iter()
|
|
101
|
+
.map(|name| ListItem {
|
|
102
|
+
blocks: vec![Block::Paragraph {
|
|
103
|
+
text: vec![Inline::Link {
|
|
104
|
+
text: vec![Inline::Text { text: name.to_string() }],
|
|
105
|
+
target: LinkTarget::Vex { name: name.to_string() },
|
|
106
|
+
}],
|
|
107
|
+
}],
|
|
108
|
+
props: Vec::new(),
|
|
109
|
+
})
|
|
110
|
+
.collect();
|
|
111
|
+
blocks.push(Block::Section {
|
|
112
|
+
name: tag.to_string(),
|
|
113
|
+
title: None,
|
|
114
|
+
props: Vec::new(),
|
|
115
|
+
children: vec![Block::Bullets { items }],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|