longecho 0.3.0__py3-none-any.whl

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.
longecho/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """longecho -- compliance validator, discovery, and site builder for durable personal archives."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from .build import BuildResult, build_site
6
+ from .checker import (
7
+ DURABLE_FORMAT_CATEGORIES,
8
+ ComplianceResult,
9
+ EchoSource,
10
+ Readme,
11
+ check_compliance,
12
+ parse_readme,
13
+ )
14
+ from .discovery import discover_sources, matches_query, search_sources
15
+
16
+ __all__ = [
17
+ "BuildResult",
18
+ "ComplianceResult",
19
+ "DURABLE_FORMAT_CATEGORIES",
20
+ "EchoSource",
21
+ "Readme",
22
+ "__version__",
23
+ "build_site",
24
+ "check_compliance",
25
+ "discover_sources",
26
+ "matches_query",
27
+ "parse_readme",
28
+ "search_sources",
29
+ ]
longecho/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running longecho as a module: python -m longecho."""
2
+
3
+ from .cli import app
4
+
5
+ app()
longecho/build.py ADDED
@@ -0,0 +1,250 @@
1
+ """longecho site builder -- generates a single-file application from a longecho archive."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import date, datetime
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from jinja2 import Environment, PackageLoader, select_autoescape
10
+
11
+ from . import __version__
12
+ from .checker import EchoSource, check_compliance
13
+ from .discovery import should_skip_directory
14
+
15
+
16
+ @dataclass
17
+ class BuildResult:
18
+ """Result of a site build operation."""
19
+
20
+ success: bool
21
+ output_path: Optional[Path] = None
22
+ sources_count: int = 0
23
+ error: Optional[str] = None
24
+
25
+
26
+ def get_jinja_env() -> Environment:
27
+ """Create Jinja2 environment with templates."""
28
+ return Environment(
29
+ loader=PackageLoader("longecho", "templates"),
30
+ autoescape=select_autoescape(["html", "xml"]),
31
+ )
32
+
33
+
34
+ def _sanitize_html(html: str) -> str:
35
+ """Strip dangerous HTML elements from rendered markdown."""
36
+ # Remove dangerous tags with content
37
+ html = re.sub(
38
+ r"<(script|style|iframe|object|embed)\b[^>]*>.*?</\1>",
39
+ "", html, flags=re.DOTALL | re.IGNORECASE,
40
+ )
41
+ # Remove self-closing dangerous tags
42
+ html = re.sub(
43
+ r"<(script|style|iframe|object|embed|base|meta|link)\b[^>]*/?>",
44
+ "", html, flags=re.IGNORECASE,
45
+ )
46
+ # Remove opening-only dangerous tags (base, meta, link are void elements)
47
+ html = re.sub(
48
+ r"<(base|meta|link)\b[^>]*>",
49
+ "", html, flags=re.IGNORECASE,
50
+ )
51
+ # Remove form tags
52
+ html = re.sub(r"</?form\b[^>]*>", "", html, flags=re.IGNORECASE)
53
+ # Remove on* event handlers (quoted)
54
+ html = re.sub(r"\s+on\w+\s*=\s*[\"'][^\"']*[\"']", "", html, flags=re.IGNORECASE)
55
+ # Remove on* event handlers (unquoted, stop at > or whitespace)
56
+ html = re.sub(r"\s+on\w+\s*=\s*[^\s>]+", "", html, flags=re.IGNORECASE)
57
+ # Remove javascript: URIs in href/src/action attributes
58
+ html = re.sub(
59
+ r'(href|src|action)\s*=\s*["\']?\s*javascript:[^"\'>\s]*["\']?',
60
+ "", html, flags=re.IGNORECASE,
61
+ )
62
+ return html
63
+
64
+
65
+ def markdown_to_html(content: str) -> str:
66
+ """Convert markdown to sanitized HTML."""
67
+ import markdown
68
+
69
+ html: str = markdown.markdown(content, extensions=["fenced_code", "tables"])
70
+ return _sanitize_html(html)
71
+
72
+
73
+ def _get_data_files(source: EchoSource, output_path: Path) -> list[dict]:
74
+ """Get data files in a source directory, with relative paths from output_path."""
75
+ files = []
76
+ for fmt in source.durable_formats:
77
+ for f in source.path.glob(f"*{fmt}"):
78
+ if f.is_file():
79
+ try:
80
+ rel = Path(f.relative_to(output_path.parent))
81
+ except ValueError:
82
+ # output_path is outside the archive — use absolute file:// URI
83
+ rel = Path(f.as_uri()) if hasattr(f, "as_uri") else f
84
+ files.append({"name": f.name, "path": str(rel)})
85
+ return sorted(files, key=lambda x: x["name"])
86
+
87
+
88
+ def discover_sub_sources(source: EchoSource) -> list[EchoSource]:
89
+ """Discover sub-sources using contents field or auto-discovery."""
90
+ path = source.path
91
+ sources: list[EchoSource] = []
92
+
93
+ if source.contents:
94
+ # Curated: only listed paths, in order
95
+ for entry in source.contents:
96
+ entry_path = entry.get("path", "")
97
+ sub_path = (path / entry_path).resolve()
98
+ # Prevent path traversal
99
+ if not sub_path.is_relative_to(path):
100
+ continue
101
+ if not sub_path.is_dir():
102
+ continue
103
+
104
+ result = check_compliance(sub_path)
105
+ if result.compliant and result.source:
106
+ sources.append(result.source)
107
+ else:
108
+ # Auto-discover: all compliant subdirectories, alphabetical
109
+ for item in sorted(path.iterdir()):
110
+ if not item.is_dir():
111
+ continue
112
+ if item.name == "site" or should_skip_directory(item.name):
113
+ continue
114
+
115
+ result = check_compliance(item)
116
+ if result.compliant and result.source:
117
+ sources.append(result.source)
118
+
119
+ return sources
120
+
121
+
122
+ def make_json_safe(obj: object) -> object:
123
+ """Recursively convert non-JSON-serializable types (e.g. datetime.date) to strings."""
124
+ if isinstance(obj, dict):
125
+ return {k: make_json_safe(v) for k, v in obj.items()}
126
+ if isinstance(obj, list):
127
+ return [make_json_safe(v) for v in obj]
128
+ if isinstance(obj, (date, datetime)):
129
+ return obj.isoformat()
130
+ return obj
131
+
132
+
133
+ def _source_to_json(source: EchoSource, output_path: Path) -> dict:
134
+ """Convert an EchoSource to a JSON-serializable dict for the SFA, recursively."""
135
+ readme_html = ""
136
+ try:
137
+ content = source.readme_path.read_text(encoding="utf-8")
138
+ readme_html = markdown_to_html(content)
139
+ except (OSError, UnicodeDecodeError):
140
+ pass
141
+
142
+ frontmatter: dict = make_json_safe(source.frontmatter or {}) # type: ignore[assignment]
143
+
144
+ # Recursively discover and convert children
145
+ child_sources = discover_sub_sources(source)
146
+ children = [_source_to_json(c, output_path) for c in child_sources]
147
+
148
+ # Compute relative path to source's site/index.html if it exists
149
+ site_url = None
150
+ if source.has_site and source.site_path:
151
+ try:
152
+ rel = source.site_path / "index.html"
153
+ site_url = str(rel.relative_to(output_path.parent))
154
+ except ValueError:
155
+ site_url = (source.site_path / "index.html").as_uri()
156
+
157
+ return {
158
+ "name": source.name,
159
+ "description": source.description,
160
+ "formats": source.durable_formats,
161
+ "frontmatter": frontmatter,
162
+ "readme_html": readme_html,
163
+ "data_files": _get_data_files(source, output_path),
164
+ "site_url": site_url,
165
+ "children": children,
166
+ }
167
+
168
+
169
+ def _generate_site_readme(
170
+ name: str,
171
+ sources: list[EchoSource],
172
+ output_path: Path,
173
+ ) -> None:
174
+ """Generate a longecho-compliant README.md for the site/ directory."""
175
+ import yaml
176
+
177
+ source_names = ", ".join(s.name for s in sources)
178
+ now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
179
+
180
+ frontmatter = {
181
+ "name": f"{name} Site",
182
+ "description": "Static site generated from longecho archive",
183
+ "generator": f"longecho build v{__version__}",
184
+ "datetime": now,
185
+ "contents": [
186
+ {"path": "index.html", "description": "Single-file browsable archive"},
187
+ ],
188
+ }
189
+ fm_yaml = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False)
190
+
191
+ content = f"---\n{fm_yaml}---\n\nGenerated by longecho from {len(sources)} source(s): {source_names}.\nOpen index.html in any browser to explore.\n"
192
+ readme_path = output_path / "README.md"
193
+ readme_path.write_text(content, encoding="utf-8")
194
+
195
+
196
+ def _count_sources(data: list[dict]) -> int:
197
+ """Count all sources including nested children."""
198
+ total = len(data)
199
+ for s in data:
200
+ total += _count_sources(s.get("children", []))
201
+ return total
202
+
203
+
204
+ def build_site(
205
+ path: Path,
206
+ output: Optional[Path] = None,
207
+ ) -> BuildResult:
208
+ """Build a single-file application for a longecho archive."""
209
+ path = Path(path).resolve()
210
+
211
+ if not path.exists():
212
+ return BuildResult(success=False, error=f"Path does not exist: {path}")
213
+
214
+ if not path.is_dir():
215
+ return BuildResult(success=False, error=f"Path is not a directory: {path}")
216
+
217
+ result = check_compliance(path)
218
+ if not result.compliant or not result.source:
219
+ return BuildResult(
220
+ success=False,
221
+ error=f"Not a longecho archive: {result.reason}"
222
+ )
223
+
224
+ root_source = result.source
225
+ sources = discover_sub_sources(root_source)
226
+
227
+ output_path = Path(output).resolve() if output else path / "site"
228
+ output_path.mkdir(parents=True, exist_ok=True)
229
+
230
+ sources_data = [_source_to_json(s, output_path) for s in sources]
231
+
232
+ env = get_jinja_env()
233
+ template = env.get_template("sfa.html")
234
+ html = template.render(
235
+ name=root_source.name,
236
+ description=root_source.description,
237
+ sources_data=sources_data,
238
+ generated_at=datetime.now().strftime("%Y-%m-%d %H:%M"),
239
+ )
240
+
241
+ index_path = output_path / "index.html"
242
+ index_path.write_text(html, encoding="utf-8")
243
+
244
+ _generate_site_readme(root_source.name, sources, output_path)
245
+
246
+ return BuildResult(
247
+ success=True,
248
+ output_path=output_path,
249
+ sources_count=_count_sources(sources_data),
250
+ )
longecho/checker.py ADDED
@@ -0,0 +1,253 @@
1
+ """longecho compliance checker."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import yaml
8
+
9
+ # Single source of truth for durable format categories.
10
+ # All display, spec, and compliance logic derives from this structure.
11
+ DURABLE_FORMAT_CATEGORIES: dict[str, list[str]] = {
12
+ "Structured data": [".db", ".sqlite", ".sqlite3", ".json", ".jsonl"],
13
+ "Documents": [".md", ".markdown", ".txt", ".text", ".rst", ".html", ".htm"],
14
+ "Archives": [".zip"],
15
+ "Images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
16
+ "Tabular / data": [".csv", ".tsv", ".xml", ".yaml", ".yml"],
17
+ }
18
+
19
+ # Derived flat set for O(1) membership testing
20
+ DURABLE_EXTENSIONS: set[str] = {
21
+ ext for exts in DURABLE_FORMAT_CATEGORIES.values() for ext in exts
22
+ }
23
+
24
+ EXCLUDE_PATTERNS: set[str] = {
25
+ "README.md", "README.txt",
26
+ "CLAUDE.md", "CHANGELOG.md",
27
+ ".gitignore", ".gitattributes",
28
+ "pyproject.toml", "setup.py", "setup.cfg",
29
+ "requirements.txt",
30
+ }
31
+
32
+ DEFAULT_FORMAT_SCAN_DEPTH: int = 2
33
+ MAX_README_SUMMARY_LENGTH: int = 500
34
+
35
+
36
+ @dataclass
37
+ class Readme:
38
+ """A README parsed into its constituent parts."""
39
+
40
+ frontmatter: Optional[dict]
41
+ body: str
42
+ title: Optional[str]
43
+ summary: Optional[str]
44
+
45
+
46
+ def _split_frontmatter(content: str) -> tuple[Optional[dict], str]:
47
+ """Split YAML frontmatter from markdown content.
48
+
49
+ Returns (frontmatter dict or None, remaining body).
50
+ """
51
+ if not content.startswith("---"):
52
+ return None, content
53
+
54
+ lines = content.split("\n")
55
+ # Find closing ---
56
+ for i, line in enumerate(lines[1:], start=1):
57
+ if line.strip() == "---":
58
+ yaml_content = "\n".join(lines[1:i])
59
+ body = "\n".join(lines[i + 1 :])
60
+ try:
61
+ parsed = yaml.safe_load(yaml_content)
62
+ if isinstance(parsed, dict):
63
+ return parsed, body
64
+ return None, body
65
+ except yaml.YAMLError:
66
+ return None, body
67
+
68
+ # No closing delimiter found
69
+ return None, content
70
+
71
+
72
+ def parse_readme(readme_path: Path) -> Optional[Readme]:
73
+ """Parse a README into frontmatter, title, and summary. Returns None if unreadable."""
74
+ try:
75
+ content = readme_path.read_text(encoding="utf-8")
76
+ except (OSError, UnicodeDecodeError):
77
+ return None
78
+
79
+ frontmatter, body = _split_frontmatter(content)
80
+
81
+ title = None
82
+ summary_lines: list[str] = []
83
+ in_paragraph = False
84
+
85
+ for line in body.split("\n"):
86
+ stripped = line.strip()
87
+
88
+ if stripped.startswith("# ") and title is None:
89
+ title = stripped[2:].strip()
90
+ continue
91
+
92
+ if stripped.startswith("#"):
93
+ if summary_lines:
94
+ break
95
+ continue
96
+
97
+ if not stripped:
98
+ if in_paragraph:
99
+ break
100
+ continue
101
+
102
+ in_paragraph = True
103
+ summary_lines.append(stripped)
104
+
105
+ summary = " ".join(summary_lines)[:MAX_README_SUMMARY_LENGTH] if summary_lines else None
106
+ return Readme(frontmatter=frontmatter, body=body, title=title, summary=summary)
107
+
108
+
109
+ @dataclass
110
+ class EchoSource:
111
+ """A longecho-compliant data source."""
112
+
113
+ path: Path
114
+ readme_path: Path
115
+ name: str
116
+ description: str
117
+ durable_formats: list[str] = field(default_factory=list)
118
+ has_site: bool = False
119
+ site_path: Optional[Path] = None
120
+ frontmatter: Optional[dict] = None
121
+ contents: Optional[list[dict]] = None
122
+
123
+ def __str__(self) -> str:
124
+ desc = self.description or "No description"
125
+ if len(desc) > 60:
126
+ desc = desc[:57] + "..."
127
+ return f"{self.path}: {desc}"
128
+
129
+
130
+ @dataclass
131
+ class ComplianceResult:
132
+ """Result of a longecho compliance check."""
133
+
134
+ compliant: bool
135
+ path: Path
136
+ reason: Optional[str] = None
137
+ source: Optional[EchoSource] = None # populated when compliant
138
+
139
+ def __str__(self) -> str:
140
+ if self.compliant:
141
+ return f"longecho-compliant: {self.path}"
142
+ return f"Not longecho-compliant: {self.path} ({self.reason})"
143
+
144
+
145
+ def find_readme(path: Path) -> Optional[Path]:
146
+ """Find README file at the root of a directory."""
147
+ for name in ["README.md", "README.txt", "readme.md", "readme.txt"]:
148
+ readme = path / name
149
+ if readme.is_file():
150
+ return readme
151
+ return None
152
+
153
+
154
+ def detect_durable_formats(path: Path, max_depth: int = DEFAULT_FORMAT_SCAN_DEPTH) -> list[str]:
155
+ """Detect durable file formats in a directory up to max_depth."""
156
+ found: set[str] = set()
157
+
158
+ def scan_directory(dir_path: Path, depth: int):
159
+ if depth > max_depth:
160
+ return
161
+
162
+ try:
163
+ for item in dir_path.iterdir():
164
+ if item.name.startswith("."):
165
+ continue
166
+
167
+ if item.is_file():
168
+ if item.name not in EXCLUDE_PATTERNS:
169
+ suffix = item.suffix.lower()
170
+ if suffix in DURABLE_EXTENSIONS:
171
+ found.add(suffix)
172
+ elif item.is_dir():
173
+ scan_directory(item, depth + 1)
174
+ except PermissionError:
175
+ pass
176
+
177
+ scan_directory(path, 0)
178
+ return sorted(found)
179
+
180
+
181
+ def is_durable_format(extension: str) -> bool:
182
+ """Check if a file extension is a durable format (with or without leading dot)."""
183
+ ext = extension.lower()
184
+ if not ext.startswith("."):
185
+ ext = "." + ext
186
+ return ext in DURABLE_EXTENSIONS
187
+
188
+
189
+ def _parse_contents(frontmatter: dict) -> Optional[list[dict]]:
190
+ """Parse the contents field from frontmatter into normalized dicts."""
191
+ raw = frontmatter.get("contents")
192
+ if not isinstance(raw, list):
193
+ return None
194
+
195
+ entries = []
196
+ for item in raw:
197
+ if isinstance(item, dict) and "path" in item:
198
+ entries.append(item)
199
+ return entries if entries else None
200
+
201
+
202
+ def check_compliance(path: Path) -> ComplianceResult:
203
+ """Check if a directory is longecho-compliant (has README + durable formats)."""
204
+ path = Path(path).resolve()
205
+
206
+ if not path.exists():
207
+ return ComplianceResult(compliant=False, path=path, reason="Path does not exist")
208
+ if not path.is_dir():
209
+ return ComplianceResult(compliant=False, path=path, reason="Path is not a directory")
210
+
211
+ readme_file = find_readme(path)
212
+ if not readme_file:
213
+ return ComplianceResult(
214
+ compliant=False, path=path, reason="No README.md or README.txt found"
215
+ )
216
+
217
+ durable = detect_durable_formats(path)
218
+
219
+ if not durable:
220
+ return ComplianceResult(
221
+ compliant=False, path=path, reason="No durable data formats found"
222
+ )
223
+
224
+ readme = parse_readme(readme_file)
225
+
226
+ # Name cascade: frontmatter name > # Heading > dirname
227
+ name = (readme.title if readme else None) or path.name
228
+ description = (readme.summary if readme else None) or ""
229
+ frontmatter = readme.frontmatter if readme else None
230
+ contents = None
231
+
232
+ if frontmatter:
233
+ name = frontmatter.get("name", name)
234
+ description = frontmatter.get("description", description)
235
+ contents = _parse_contents(frontmatter)
236
+
237
+ site_dir = path / "site"
238
+ has_site = site_dir.exists() and (site_dir / "index.html").exists()
239
+ site_path = site_dir if has_site else None
240
+
241
+ source = EchoSource(
242
+ path=path,
243
+ readme_path=readme_file,
244
+ name=name,
245
+ description=description,
246
+ durable_formats=durable,
247
+ has_site=has_site,
248
+ site_path=site_path,
249
+ frontmatter=frontmatter,
250
+ contents=contents,
251
+ )
252
+
253
+ return ComplianceResult(compliant=True, path=path, source=source)