2025-11-05 16:48:41 +00:00
|
|
|
"""
|
2026-06-30 09:14:42 +00:00
|
|
|
Utility functions for file operations, search, and markdown processing.
|
|
|
|
|
|
|
|
|
|
Heavy work (backlinks, graph, full-text search, tag aggregation) is delegated
|
2026-06-30 09:59:25 +00:00
|
|
|
to the in-memory index in note_index.py.
|
2025-11-05 16:48:41 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import shutil
|
2026-02-20 07:39:08 +00:00
|
|
|
import threading
|
|
|
|
|
import time
|
2026-06-30 09:14:42 +00:00
|
|
|
import traceback
|
2026-03-24 11:07:02 +00:00
|
|
|
import urllib.parse
|
2026-06-29 15:08:52 +00:00
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-03-23 16:47:28 +00:00
|
|
|
from dataclasses import dataclass
|
2025-11-05 16:48:41 +00:00
|
|
|
from pathlib import Path
|
2026-03-23 16:47:28 +00:00
|
|
|
from typing import List, Dict, Optional, Tuple, Any, TypeVar, Callable
|
2025-12-12 16:35:04 +00:00
|
|
|
from datetime import datetime, timezone
|
2025-11-05 16:48:41 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
from . import note_index
|
|
|
|
|
from .note_index import NoteRecord, extract_links_from_content
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
2026-03-23 16:47:28 +00:00
|
|
|
# ============================================================================
|
|
|
|
|
# Pagination Support
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class PaginationResult:
|
|
|
|
|
"""
|
|
|
|
|
Result of applying pagination to a list.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
items: The paginated subset of items
|
|
|
|
|
total: Total number of items before pagination
|
|
|
|
|
limit: The limit that was applied (None if no pagination)
|
|
|
|
|
offset: The offset that was applied
|
|
|
|
|
has_more: Whether there are more items after this page
|
|
|
|
|
"""
|
|
|
|
|
items: List[Any]
|
|
|
|
|
total: int
|
|
|
|
|
limit: Optional[int]
|
|
|
|
|
offset: int
|
|
|
|
|
has_more: bool
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
"""Convert pagination info to dict for API response."""
|
|
|
|
|
return {
|
|
|
|
|
"limit": self.limit,
|
|
|
|
|
"offset": self.offset,
|
|
|
|
|
"total": self.total,
|
|
|
|
|
"has_more": self.has_more
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
T = TypeVar('T')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def paginate(
|
|
|
|
|
items: List[T],
|
|
|
|
|
limit: Optional[int] = None,
|
|
|
|
|
offset: int = 0,
|
|
|
|
|
sort_key: Optional[Callable[[T], Any]] = None,
|
|
|
|
|
sort_reverse: bool = False
|
|
|
|
|
) -> PaginationResult:
|
|
|
|
|
"""
|
|
|
|
|
Apply optional pagination to a list with consistent sorting.
|
|
|
|
|
|
|
|
|
|
This function is designed to be backward-compatible:
|
|
|
|
|
- If limit is None, returns all items (no pagination)
|
|
|
|
|
- If limit is provided, returns a paginated subset
|
|
|
|
|
|
|
|
|
|
Sorting is always applied (when sort_key is provided) to ensure
|
|
|
|
|
stable pagination across requests.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
items: List of items to paginate
|
|
|
|
|
limit: Maximum number of items to return (None = no limit)
|
|
|
|
|
offset: Number of items to skip (default: 0)
|
|
|
|
|
sort_key: Function to extract sort key from item (e.g., lambda x: x['path'])
|
|
|
|
|
sort_reverse: If True, sort in descending order
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
PaginationResult with items and pagination metadata
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
# No pagination (frontend compatibility)
|
|
|
|
|
result = paginate(notes)
|
|
|
|
|
|
|
|
|
|
# With pagination (MCP usage)
|
|
|
|
|
result = paginate(notes, limit=20, offset=0, sort_key=lambda x: x['path'])
|
|
|
|
|
"""
|
|
|
|
|
total = len(items)
|
|
|
|
|
|
|
|
|
|
# Apply sorting for consistent ordering (prevents out-of-order issues)
|
|
|
|
|
if sort_key is not None:
|
|
|
|
|
items = sorted(items, key=sort_key, reverse=sort_reverse)
|
|
|
|
|
|
|
|
|
|
# Apply pagination only if limit is specified
|
|
|
|
|
if limit is not None:
|
|
|
|
|
# Clamp offset to valid range
|
|
|
|
|
offset = max(0, min(offset, total))
|
|
|
|
|
end = offset + limit
|
|
|
|
|
paginated_items = items[offset:end]
|
|
|
|
|
has_more = end < total
|
|
|
|
|
else:
|
|
|
|
|
# No pagination - return all items
|
|
|
|
|
paginated_items = items
|
|
|
|
|
offset = 0
|
|
|
|
|
has_more = False
|
|
|
|
|
|
|
|
|
|
return PaginationResult(
|
|
|
|
|
items=paginated_items,
|
|
|
|
|
total=total,
|
|
|
|
|
limit=limit,
|
|
|
|
|
offset=offset,
|
|
|
|
|
has_more=has_more
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
# ============================================================================
|
|
|
|
|
# In-memory caches
|
|
|
|
|
#
|
|
|
|
|
# Two layers:
|
|
|
|
|
# _tag_cache / _links_cache : per-file, mtime-keyed. Avoid re-parsing the
|
|
|
|
|
# same file on warm scans. Used by get_tags_and_links_cached.
|
|
|
|
|
# _SCAN_WALK_CACHE : per-scan TTL cache. Avoids repeated full-
|
|
|
|
|
# directory walks when several endpoints fire in quick succession.
|
2026-02-20 07:39:08 +00:00
|
|
|
#
|
2026-06-30 09:14:42 +00:00
|
|
|
# All three are invalidated on every mutation (save/delete/move). The
|
|
|
|
|
# note_index in note_index.py is the system of record for derived data
|
|
|
|
|
# (links, backlinks, tags-by-name, search) — these caches just exist to
|
|
|
|
|
# avoid double-reading files we already parsed.
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
_tag_cache: Dict[str, Tuple[float, List[str]]] = {}
|
|
|
|
|
_links_cache: Dict[str, Tuple[float, Dict[str, List[str]]]] = {}
|
2026-02-20 07:39:08 +00:00
|
|
|
|
|
|
|
|
_SCAN_WALK_CACHE_LOCK = threading.Lock()
|
|
|
|
|
_SCAN_WALK_CACHE_TTL_SECONDS = 1.0
|
2026-02-20 18:41:51 +00:00
|
|
|
# key: (resolved_notes_dir, include_media) -> (cached_at_monotonic_seconds, (notes, folders))
|
|
|
|
|
_SCAN_WALK_CACHE: Dict[Tuple[str, bool], Tuple[float, Tuple[List[Dict], List[str]]]] = {}
|
2026-02-20 07:39:08 +00:00
|
|
|
|
|
|
|
|
|
2026-02-20 18:41:51 +00:00
|
|
|
def _scan_cache_get(key: Tuple[str, bool]) -> Optional[Tuple[List[Dict], List[str]]]:
|
2026-02-20 07:39:08 +00:00
|
|
|
now = time.monotonic()
|
|
|
|
|
with _SCAN_WALK_CACHE_LOCK:
|
|
|
|
|
entry = _SCAN_WALK_CACHE.get(key)
|
|
|
|
|
if not entry:
|
|
|
|
|
return None
|
|
|
|
|
cached_at, value = entry
|
|
|
|
|
if (now - cached_at) > _SCAN_WALK_CACHE_TTL_SECONDS:
|
|
|
|
|
_SCAN_WALK_CACHE.pop(key, None)
|
|
|
|
|
return None
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 18:44:23 +00:00
|
|
|
def _scan_cache_set(key: Tuple[str, bool], value: Tuple[List[Dict], List[str]]) -> None:
|
2026-02-20 07:39:08 +00:00
|
|
|
with _SCAN_WALK_CACHE_LOCK:
|
|
|
|
|
_SCAN_WALK_CACHE[key] = (time.monotonic(), value)
|
2025-11-24 16:11:22 +00:00
|
|
|
|
2026-02-20 06:48:31 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
def _scan_cache_invalidate() -> None:
|
2026-06-30 09:59:25 +00:00
|
|
|
"""Drop the TTL scan cache. Called from every mutation handler."""
|
2026-06-30 09:14:42 +00:00
|
|
|
with _SCAN_WALK_CACHE_LOCK:
|
|
|
|
|
_SCAN_WALK_CACHE.clear()
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 11:47:19 +00:00
|
|
|
def ensure_index_built(notes_dir: str) -> None:
|
2026-06-30 09:59:25 +00:00
|
|
|
"""Trigger a fresh scan when the NoteIndex hasn't been populated yet.
|
2026-06-30 11:47:19 +00:00
|
|
|
Scans with include_media=True so every index consumer (stats, notes,
|
|
|
|
|
backlinks, search) shares one fingerprint and never thrashes."""
|
2026-06-30 09:59:25 +00:00
|
|
|
if not note_index.get_index().is_built():
|
2026-06-30 11:47:19 +00:00
|
|
|
scan_notes_fast_walk(notes_dir, use_cache=False, include_media=True)
|
2026-06-30 09:59:25 +00:00
|
|
|
|
|
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
def get_tags_and_links_cached(
|
|
|
|
|
file_path: Path,
|
|
|
|
|
rel_path: Optional[str] = None,
|
|
|
|
|
) -> Tuple[List[str], Dict[str, List[str]]]:
|
2026-06-30 09:59:25 +00:00
|
|
|
"""Fused tags + raw-links extraction, mtime-cached. Tries in-process
|
|
|
|
|
per-file caches first, then the NoteIndex (when rel_path is provided),
|
|
|
|
|
then reads the file."""
|
2026-06-30 09:14:42 +00:00
|
|
|
try:
|
|
|
|
|
mtime = file_path.stat().st_mtime
|
|
|
|
|
file_key = str(file_path)
|
|
|
|
|
|
|
|
|
|
tag_cached = _tag_cache.get(file_key)
|
|
|
|
|
link_cached = _links_cache.get(file_key)
|
|
|
|
|
tags_ok = tag_cached is not None and tag_cached[0] == mtime
|
|
|
|
|
links_ok = link_cached is not None and link_cached[0] == mtime
|
|
|
|
|
|
|
|
|
|
if tags_ok and links_ok:
|
|
|
|
|
return tag_cached[1], link_cached[1]
|
|
|
|
|
|
|
|
|
|
if rel_path is not None:
|
|
|
|
|
indexed = note_index.try_get_extraction(rel_path, mtime)
|
|
|
|
|
if indexed is not None:
|
|
|
|
|
idx_tags, idx_links = indexed
|
|
|
|
|
_tag_cache[file_key] = (mtime, idx_tags)
|
|
|
|
|
_links_cache[file_key] = (mtime, idx_links)
|
|
|
|
|
return idx_tags, idx_links
|
|
|
|
|
|
|
|
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
content = f.read()
|
|
|
|
|
|
|
|
|
|
tags = tag_cached[1] if tags_ok else parse_tags(content)
|
|
|
|
|
links = link_cached[1] if links_ok else extract_links_from_content(content)
|
|
|
|
|
|
|
|
|
|
if not tags_ok:
|
|
|
|
|
_tag_cache[file_key] = (mtime, tags)
|
|
|
|
|
if not links_ok:
|
|
|
|
|
_links_cache[file_key] = (mtime, links)
|
|
|
|
|
|
|
|
|
|
return tags, links
|
|
|
|
|
except Exception:
|
|
|
|
|
return [], {"wikilinks": [], "mdlinks": []}
|
|
|
|
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
def validate_path_security(notes_dir: str, path: Path) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Validate that a path is within the notes directory (security check).
|
|
|
|
|
Prevents path traversal attacks.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
notes_dir: Base notes directory
|
|
|
|
|
path: Path to validate
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if path is safe, False otherwise
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
path.resolve().relative_to(Path(notes_dir).resolve())
|
|
|
|
|
return True
|
|
|
|
|
except ValueError:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_directories(config: dict):
|
|
|
|
|
"""Create necessary directories if they don't exist"""
|
|
|
|
|
dirs = [
|
|
|
|
|
config['storage']['notes_dir'],
|
|
|
|
|
config['storage']['plugins_dir'],
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for dir_path in dirs:
|
|
|
|
|
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_folder(notes_dir: str, folder_path: str) -> bool:
|
2026-06-30 09:14:42 +00:00
|
|
|
"""Create a new folder in the notes directory."""
|
2025-11-05 16:48:41 +00:00
|
|
|
full_path = Path(notes_dir) / folder_path
|
|
|
|
|
if not validate_path_security(notes_dir, full_path):
|
|
|
|
|
return False
|
|
|
|
|
full_path.mkdir(parents=True, exist_ok=True)
|
2026-06-30 09:14:42 +00:00
|
|
|
_scan_cache_invalidate()
|
2025-11-05 16:48:41 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 18:41:51 +00:00
|
|
|
def scan_notes_fast_walk(notes_dir: str, use_cache: bool = True, include_media: bool = False) -> Tuple[List[Dict], List[str]]:
|
2026-02-20 06:48:31 +00:00
|
|
|
"""Fast scanner using os.walk (pure Python + stdlib).
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
notes_dir: Base notes directory
|
|
|
|
|
"""
|
2025-11-05 16:48:41 +00:00
|
|
|
notes_path = Path(notes_dir)
|
|
|
|
|
|
2026-02-20 18:41:51 +00:00
|
|
|
cache_key = (str(notes_path.resolve()), include_media)
|
2026-02-20 07:39:08 +00:00
|
|
|
if use_cache:
|
|
|
|
|
cached = _scan_cache_get(cache_key)
|
|
|
|
|
if cached is not None:
|
|
|
|
|
return cached
|
|
|
|
|
|
2026-02-20 18:41:51 +00:00
|
|
|
if not include_media:
|
|
|
|
|
media_cache_key = (str(notes_path.resolve()), True)
|
|
|
|
|
media_cached = _scan_cache_get(media_cache_key)
|
|
|
|
|
if media_cached is not None:
|
|
|
|
|
media_notes, media_folders = media_cached
|
|
|
|
|
normalized_notes = []
|
|
|
|
|
for note in media_notes:
|
|
|
|
|
if not Path(note.get("path", "")).match("*.md"):
|
|
|
|
|
continue
|
|
|
|
|
normalized_note = dict(note)
|
|
|
|
|
normalized_note["type"] = "note"
|
|
|
|
|
normalized_notes.append(normalized_note)
|
|
|
|
|
|
|
|
|
|
normalized_value = (normalized_notes, media_folders)
|
|
|
|
|
_scan_cache_set(cache_key, normalized_value)
|
|
|
|
|
return normalized_value
|
|
|
|
|
|
2026-06-30 09:59:25 +00:00
|
|
|
# Walk the vault once. Tag/link extraction is parallelized below across
|
|
|
|
|
# the markdown files we collected here.
|
2026-02-20 06:48:31 +00:00
|
|
|
notes: List[Dict] = []
|
|
|
|
|
folders_set = set()
|
2026-06-30 09:59:25 +00:00
|
|
|
md_to_extract: List[Tuple[int, Path, str]] = [] # (index, full_path, rel_path)
|
2026-02-20 06:48:31 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
for root, dirnames, filenames in os.walk(notes_path):
|
|
|
|
|
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
2026-06-29 15:08:52 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
root_path = Path(root)
|
|
|
|
|
rel_folder = root_path.relative_to(notes_path).as_posix()
|
|
|
|
|
if rel_folder != "." and not rel_folder.startswith('.'):
|
|
|
|
|
folders_set.add(rel_folder)
|
2026-06-29 15:08:52 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
for filename in filenames:
|
|
|
|
|
if filename.startswith('.'):
|
|
|
|
|
continue
|
2026-06-29 15:08:52 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
full_path = root_path / filename
|
|
|
|
|
try:
|
|
|
|
|
st = full_path.stat()
|
|
|
|
|
except OSError:
|
|
|
|
|
continue
|
2026-06-29 15:08:52 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
relative_path = full_path.relative_to(notes_path)
|
|
|
|
|
media_type = get_media_type(filename) if include_media else None
|
|
|
|
|
is_markdown = full_path.suffix.lower() == '.md'
|
|
|
|
|
should_include = is_markdown or (include_media and media_type is not None)
|
|
|
|
|
if not should_include:
|
|
|
|
|
continue
|
2026-06-29 15:08:52 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
folder = relative_path.parent.as_posix()
|
|
|
|
|
rel_str = relative_path.as_posix()
|
|
|
|
|
notes.append({
|
|
|
|
|
"name": full_path.stem,
|
|
|
|
|
"path": rel_str,
|
|
|
|
|
"folder": "" if folder == "." else folder,
|
|
|
|
|
"modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
|
|
|
|
"size": st.st_size,
|
|
|
|
|
"type": media_type if media_type else "note",
|
|
|
|
|
"tags": [],
|
|
|
|
|
"_mtime": st.st_mtime, # internal — popped before returning
|
|
|
|
|
})
|
|
|
|
|
if is_markdown:
|
|
|
|
|
md_to_extract.append((len(notes) - 1, full_path, rel_str))
|
|
|
|
|
|
2026-06-30 09:59:25 +00:00
|
|
|
# Tag + raw-link extraction in parallel. Search-index terms are built
|
|
|
|
|
# later, lazily, on the first /api/search call.
|
2026-06-30 09:14:42 +00:00
|
|
|
sources_raw: Dict[str, Dict[str, List[str]]] = {}
|
|
|
|
|
if md_to_extract:
|
|
|
|
|
path_pairs = [(full, rel) for (_, full, rel) in md_to_extract]
|
|
|
|
|
if len(md_to_extract) >= 50:
|
|
|
|
|
workers = min(8, (os.cpu_count() or 4))
|
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
|
|
|
extraction = list(ex.map(
|
|
|
|
|
lambda pair: get_tags_and_links_cached(pair[0], pair[1]),
|
|
|
|
|
path_pairs,
|
|
|
|
|
))
|
|
|
|
|
else:
|
|
|
|
|
extraction = [get_tags_and_links_cached(full, rel) for (full, rel) in path_pairs]
|
|
|
|
|
|
|
|
|
|
for (idx, _full, rel_str), (tags, links) in zip(md_to_extract, extraction):
|
|
|
|
|
notes[idx]["tags"] = tags
|
|
|
|
|
sources_raw[rel_str] = links
|
|
|
|
|
|
2026-06-30 09:59:25 +00:00
|
|
|
# Push the result into the NoteIndex. Short-circuits on warm scans.
|
2026-06-30 09:14:42 +00:00
|
|
|
notes_meta = [
|
|
|
|
|
NoteRecord(
|
|
|
|
|
path=n["path"],
|
|
|
|
|
name=n["name"],
|
|
|
|
|
folder=n["folder"],
|
|
|
|
|
modified=n["modified"],
|
|
|
|
|
size=n["size"],
|
|
|
|
|
type=n["type"],
|
|
|
|
|
mtime=n["_mtime"],
|
|
|
|
|
tags=tuple(n["tags"]),
|
|
|
|
|
)
|
|
|
|
|
for n in notes
|
|
|
|
|
]
|
|
|
|
|
note_index.populate_from_scan(notes_meta, folders_set, sources_raw)
|
2026-06-29 15:08:52 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
for n in notes:
|
|
|
|
|
n.pop("_mtime", None)
|
2026-02-20 06:48:31 +00:00
|
|
|
|
2026-02-20 07:39:08 +00:00
|
|
|
value = (sorted(notes, key=lambda x: x.get('modified', ''), reverse=True), sorted(folders_set))
|
|
|
|
|
if use_cache:
|
|
|
|
|
_scan_cache_set(cache_key, value)
|
|
|
|
|
return value
|
2025-11-05 16:48:41 +00:00
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
def move_note(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
2026-06-30 09:14:42 +00:00
|
|
|
"""Move a note. Returns (success, error_message)."""
|
2025-11-05 16:48:41 +00:00
|
|
|
old_full_path = Path(notes_dir) / old_path
|
|
|
|
|
new_full_path = Path(notes_dir) / new_path
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
if not validate_path_security(notes_dir, old_full_path):
|
|
|
|
|
return False, "Invalid source path"
|
|
|
|
|
if not validate_path_security(notes_dir, new_full_path):
|
|
|
|
|
return False, "Invalid destination path"
|
2025-11-05 16:48:41 +00:00
|
|
|
if not old_full_path.exists():
|
2025-12-15 11:18:14 +00:00
|
|
|
return False, f"Source note does not exist: {old_path}"
|
2025-12-11 10:00:21 +00:00
|
|
|
if new_full_path.exists():
|
2025-12-15 11:18:14 +00:00
|
|
|
return False, f"A note already exists at: {new_path}"
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
_drop_path_caches(old_full_path)
|
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
try:
|
|
|
|
|
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
old_full_path.rename(new_full_path)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return False, f"Failed to move file: {str(e)}"
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
note_index.on_note_renamed(notes_dir, old_full_path, new_full_path)
|
|
|
|
|
_scan_cache_invalidate()
|
2025-12-15 11:18:14 +00:00
|
|
|
return True, ""
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
def move_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
2026-06-30 09:14:42 +00:00
|
|
|
"""Move a folder. Returns (success, error_message)."""
|
2025-11-05 16:48:41 +00:00
|
|
|
old_full_path = Path(notes_dir) / old_path
|
|
|
|
|
new_full_path = Path(notes_dir) / new_path
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
if not validate_path_security(notes_dir, old_full_path):
|
|
|
|
|
return False, "Invalid source path"
|
|
|
|
|
if not validate_path_security(notes_dir, new_full_path):
|
|
|
|
|
return False, "Invalid destination path"
|
2025-11-05 16:48:41 +00:00
|
|
|
if not old_full_path.exists() or not old_full_path.is_dir():
|
2025-12-15 11:18:14 +00:00
|
|
|
return False, f"Source folder does not exist: {old_path}"
|
2025-11-05 16:48:41 +00:00
|
|
|
if new_full_path.exists():
|
2025-12-15 11:18:14 +00:00
|
|
|
return False, f"A folder already exists at: {new_path}"
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
_drop_prefix_caches(old_full_path)
|
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
try:
|
|
|
|
|
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
shutil.move(str(old_full_path), str(new_full_path))
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return False, f"Failed to move folder: {str(e)}"
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
note_index.on_folder_renamed(notes_dir, old_full_path, new_full_path)
|
|
|
|
|
_scan_cache_invalidate()
|
2025-12-15 11:18:14 +00:00
|
|
|
return True, ""
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
def rename_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
2026-06-30 09:14:42 +00:00
|
|
|
"""Rename a folder (same as move, named for clarity)."""
|
2025-11-05 16:48:41 +00:00
|
|
|
return move_folder(notes_dir, old_path, new_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_folder(notes_dir: str, folder_path: str) -> bool:
|
2026-06-30 09:14:42 +00:00
|
|
|
"""Delete a folder and all its contents."""
|
2025-11-05 16:48:41 +00:00
|
|
|
try:
|
|
|
|
|
full_path = Path(notes_dir) / folder_path
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2025-11-17 08:26:15 +00:00
|
|
|
if not validate_path_security(notes_dir, full_path):
|
|
|
|
|
print(f"Security: Path is outside notes directory: {full_path}")
|
|
|
|
|
return False
|
2025-11-05 16:48:41 +00:00
|
|
|
if not full_path.exists():
|
|
|
|
|
print(f"Folder does not exist: {full_path}")
|
|
|
|
|
return False
|
|
|
|
|
if not full_path.is_dir():
|
|
|
|
|
print(f"Path is not a directory: {full_path}")
|
|
|
|
|
return False
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
_drop_prefix_caches(full_path)
|
2025-11-05 16:48:41 +00:00
|
|
|
shutil.rmtree(full_path)
|
2026-06-30 09:14:42 +00:00
|
|
|
note_index.on_folder_deleted(notes_dir, full_path)
|
|
|
|
|
_scan_cache_invalidate()
|
2025-11-05 16:48:41 +00:00
|
|
|
return True
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error deleting folder '{folder_path}': {e}")
|
|
|
|
|
traceback.print_exc()
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
def _drop_path_caches(full_path: Path) -> None:
|
|
|
|
|
"""Evict the per-file mtime caches for a single note."""
|
|
|
|
|
key = str(full_path)
|
|
|
|
|
_tag_cache.pop(key, None)
|
|
|
|
|
_links_cache.pop(key, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _drop_prefix_caches(folder_full_path: Path) -> None:
|
|
|
|
|
"""Evict per-file mtime caches for every entry under a folder."""
|
|
|
|
|
prefix = str(folder_full_path)
|
|
|
|
|
for k in [k for k in _tag_cache if k.startswith(prefix)]:
|
|
|
|
|
_tag_cache.pop(k, None)
|
|
|
|
|
for k in [k for k in _links_cache if k.startswith(prefix)]:
|
|
|
|
|
_links_cache.pop(k, None)
|
|
|
|
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
|
|
|
|
|
"""Get the content of a specific note"""
|
|
|
|
|
full_path = Path(notes_dir) / note_path
|
|
|
|
|
|
|
|
|
|
if not full_path.exists() or not full_path.is_file():
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Security check: ensure the path is within notes_dir
|
|
|
|
|
if not validate_path_security(notes_dir, full_path):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
with open(full_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
return f.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_note(notes_dir: str, note_path: str, content: str) -> bool:
|
2026-06-30 09:14:42 +00:00
|
|
|
"""Save or update a note."""
|
2025-11-05 16:48:41 +00:00
|
|
|
full_path = Path(notes_dir) / note_path
|
|
|
|
|
if not note_path.endswith('.md'):
|
|
|
|
|
full_path = full_path.with_suffix('.md')
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
if not validate_path_security(notes_dir, full_path):
|
|
|
|
|
return False
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with open(full_path, 'w', encoding='utf-8') as f:
|
|
|
|
|
f.write(content)
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
# Refresh the per-file mtime caches with what we just wrote, so the next
|
|
|
|
|
# scan doesn't re-parse this file. extract_links + parse_tags + save are
|
|
|
|
|
# tiny relative to the file write.
|
|
|
|
|
try:
|
|
|
|
|
mtime = full_path.stat().st_mtime
|
|
|
|
|
file_key = str(full_path)
|
|
|
|
|
_tag_cache[file_key] = (mtime, parse_tags(content))
|
|
|
|
|
_links_cache[file_key] = (mtime, extract_links_from_content(content))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass # caches are best-effort
|
|
|
|
|
|
|
|
|
|
note_index.on_note_saved(notes_dir, full_path, content)
|
|
|
|
|
_scan_cache_invalidate()
|
2025-11-05 16:48:41 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_note(notes_dir: str, note_path: str) -> bool:
|
2026-06-30 09:14:42 +00:00
|
|
|
"""Delete a note."""
|
2025-11-05 16:48:41 +00:00
|
|
|
full_path = Path(notes_dir) / note_path
|
|
|
|
|
if not full_path.exists():
|
|
|
|
|
return False
|
|
|
|
|
if not validate_path_security(notes_dir, full_path):
|
|
|
|
|
return False
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
_drop_path_caches(full_path)
|
2025-11-05 16:48:41 +00:00
|
|
|
full_path.unlink()
|
2026-06-30 09:14:42 +00:00
|
|
|
note_index.on_note_deleted(notes_dir, full_path)
|
|
|
|
|
_scan_cache_invalidate()
|
2025-11-05 16:48:41 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def search_notes(notes_dir: str, query: str) -> List[Dict]:
|
2026-06-30 09:59:25 +00:00
|
|
|
"""Full-text search through note contents. Narrow the candidate set via
|
|
|
|
|
the inverted index, then run the snippet extractor on each candidate."""
|
2025-12-05 11:00:53 +00:00
|
|
|
from html import escape
|
2026-06-30 09:14:42 +00:00
|
|
|
results: List[Dict] = []
|
2026-02-20 06:48:31 +00:00
|
|
|
|
2026-06-30 11:47:19 +00:00
|
|
|
ensure_index_built(notes_dir)
|
2026-06-30 09:14:42 +00:00
|
|
|
note_index.ensure_search_index(notes_dir)
|
2026-06-30 09:59:25 +00:00
|
|
|
candidates = note_index.get_search_candidates(query)
|
|
|
|
|
|
|
|
|
|
idx = note_index.get_index()
|
|
|
|
|
if candidates is None:
|
|
|
|
|
# Query too short to tokenize — iterate every indexed note instead.
|
|
|
|
|
candidate_records = idx.all_note_records()
|
|
|
|
|
else:
|
|
|
|
|
candidate_records = [(p, idx.get_note_record(p)) for p in candidates]
|
|
|
|
|
candidate_records = [(p, r) for (p, r) in candidate_records if r is not None and r.type == "note"]
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2026-06-30 09:59:25 +00:00
|
|
|
candidate_records.sort(key=lambda pr: pr[1].mtime, reverse=True)
|
|
|
|
|
iterable = [{"path": p, "name": r.name} for (p, r) in candidate_records]
|
|
|
|
|
|
|
|
|
|
for note in iterable:
|
2026-06-30 09:14:42 +00:00
|
|
|
path = note["path"]
|
|
|
|
|
md_file = Path(notes_dir) / path
|
2025-11-05 16:48:41 +00:00
|
|
|
try:
|
|
|
|
|
with open(md_file, 'r', encoding='utf-8') as f:
|
|
|
|
|
content = f.read()
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2025-12-04 16:49:52 +00:00
|
|
|
matches = list(re.finditer(re.escape(query), content, re.IGNORECASE))
|
2026-06-30 09:14:42 +00:00
|
|
|
if not matches:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
matched_lines = []
|
|
|
|
|
for match in matches[:3]:
|
|
|
|
|
start_index = match.start()
|
|
|
|
|
end_index = match.end()
|
|
|
|
|
matched_text = match.group()
|
|
|
|
|
|
|
|
|
|
context_start = max(0, start_index - 15)
|
|
|
|
|
context_end = min(len(content), end_index + 15)
|
|
|
|
|
|
|
|
|
|
before = escape(content[context_start:start_index].replace('\n', ' '))
|
|
|
|
|
after = escape(content[end_index:context_end].replace('\n', ' '))
|
|
|
|
|
matched_clean = escape(matched_text.replace('\n', ' '))
|
|
|
|
|
|
|
|
|
|
snippet = f'{before}<mark class="search-highlight">{matched_clean}</mark>{after}'
|
|
|
|
|
if context_start > 0:
|
|
|
|
|
snippet = '...' + snippet
|
|
|
|
|
if context_end < len(content):
|
|
|
|
|
snippet = snippet + '...'
|
|
|
|
|
|
|
|
|
|
line_number = content.count('\n', 0, start_index) + 1
|
|
|
|
|
matched_lines.append({
|
|
|
|
|
"line_number": line_number,
|
|
|
|
|
"context": snippet,
|
2025-11-05 16:48:41 +00:00
|
|
|
})
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
relative_path = Path(path)
|
|
|
|
|
results.append({
|
|
|
|
|
"name": md_file.stem,
|
|
|
|
|
"path": str(relative_path.as_posix()),
|
|
|
|
|
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
|
|
|
|
|
"matches": matched_lines,
|
|
|
|
|
})
|
2025-11-05 16:48:41 +00:00
|
|
|
except Exception:
|
|
|
|
|
continue
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
|
|
|
|
|
"""Get metadata for a note"""
|
|
|
|
|
full_path = Path(notes_dir) / note_path
|
|
|
|
|
|
2026-04-13 14:15:03 +00:00
|
|
|
if not full_path.exists() or not full_path.is_file():
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
# Security check: ensure the path is within notes_dir (same as get_note_content)
|
|
|
|
|
if not validate_path_security(notes_dir, full_path):
|
2025-11-05 16:48:41 +00:00
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
stat = full_path.stat()
|
|
|
|
|
|
|
|
|
|
# Count lines with proper file handle management
|
|
|
|
|
with open(full_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
line_count = sum(1 for _ in f)
|
|
|
|
|
|
|
|
|
|
return {
|
2025-12-12 16:35:04 +00:00
|
|
|
"created": datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat(),
|
|
|
|
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
2025-11-05 16:48:41 +00:00
|
|
|
"size": stat.st_size,
|
|
|
|
|
"lines": line_count
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
|
|
|
def sanitize_filename(filename: str) -> str:
|
|
|
|
|
"""
|
2025-12-17 09:03:40 +00:00
|
|
|
Sanitize a filename by removing/replacing dangerous filesystem characters.
|
|
|
|
|
Supports Unicode characters (international text) while blocking:
|
|
|
|
|
- Windows forbidden: \\ / : * ? " < > |
|
|
|
|
|
- Control characters (0x00-0x1f)
|
|
|
|
|
|
|
|
|
|
Note: This is a safety net - the frontend validates before sending.
|
2025-11-20 13:10:17 +00:00
|
|
|
"""
|
2025-12-17 09:03:40 +00:00
|
|
|
if not filename:
|
|
|
|
|
return filename
|
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
# Get the extension first
|
|
|
|
|
parts = filename.rsplit('.', 1)
|
|
|
|
|
name = parts[0]
|
|
|
|
|
ext = parts[1] if len(parts) > 1 else ''
|
|
|
|
|
|
2025-12-17 09:03:40 +00:00
|
|
|
# Remove dangerous characters (replace with underscore)
|
|
|
|
|
# Blocklist approach: only remove what's truly dangerous
|
|
|
|
|
# Pattern: backslash, forward slash, colon, asterisk, question mark, quotes, angle brackets, pipe, control chars
|
|
|
|
|
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '_', name)
|
|
|
|
|
|
|
|
|
|
# Collapse multiple underscores
|
|
|
|
|
name = re.sub(r'_+', '_', name)
|
|
|
|
|
|
|
|
|
|
# Strip leading/trailing underscores and spaces
|
|
|
|
|
name = name.strip('_ ')
|
|
|
|
|
|
|
|
|
|
# Ensure we have something left
|
|
|
|
|
if not name:
|
|
|
|
|
name = 'unnamed'
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
|
|
|
# Rejoin with extension
|
|
|
|
|
return f"{name}.{ext}" if ext else name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_attachment_dir(notes_dir: str, note_path: str) -> Path:
|
|
|
|
|
"""
|
|
|
|
|
Get the attachments directory for a given note.
|
|
|
|
|
If note is in root, returns /data/_attachments/
|
|
|
|
|
If note is in folder, returns /data/folder/_attachments/
|
|
|
|
|
"""
|
|
|
|
|
if not note_path:
|
|
|
|
|
# Root level
|
|
|
|
|
return Path(notes_dir) / "_attachments"
|
|
|
|
|
|
|
|
|
|
note_path_obj = Path(note_path)
|
|
|
|
|
folder = note_path_obj.parent
|
|
|
|
|
|
|
|
|
|
if str(folder) == '.':
|
|
|
|
|
# Note is in root
|
|
|
|
|
return Path(notes_dir) / "_attachments"
|
|
|
|
|
else:
|
|
|
|
|
# Note is in a folder
|
|
|
|
|
return Path(notes_dir) / folder / "_attachments"
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 14:00:58 +00:00
|
|
|
def save_uploaded_image(
|
|
|
|
|
notes_dir: str,
|
|
|
|
|
note_path: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
file_data: bytes,
|
|
|
|
|
*,
|
|
|
|
|
sibling_folder: Optional[str] = None,
|
|
|
|
|
) -> Optional[str]:
|
2025-11-20 13:10:17 +00:00
|
|
|
"""
|
2026-04-21 14:00:58 +00:00
|
|
|
Save uploaded media under the vault.
|
|
|
|
|
|
|
|
|
|
Default (sibling_folder is None): store in ``_attachments`` next to the note implied by
|
|
|
|
|
``note_path`` (drag/drop, paste, etc.).
|
|
|
|
|
|
|
|
|
|
If ``sibling_folder`` is set (including ``""`` for vault root): store ``drawing-{timestamp}.png``
|
|
|
|
|
in that folder next to ``.md`` files — used for new drawings from the + menu.
|
|
|
|
|
|
|
|
|
|
Returns a relative path from ``notes_dir``, or None on failure.
|
2025-11-20 13:10:17 +00:00
|
|
|
"""
|
2026-04-21 14:00:58 +00:00
|
|
|
base = Path(notes_dir)
|
|
|
|
|
|
|
|
|
|
if sibling_folder is not None:
|
|
|
|
|
cf = (sibling_folder or "").strip().replace("\\", "/")
|
|
|
|
|
segments = [p for p in cf.split("/") if p and p != "."]
|
|
|
|
|
if any(p == ".." for p in segments):
|
|
|
|
|
return None
|
|
|
|
|
dest_dir = base.joinpath(*segments) if segments else base
|
|
|
|
|
if not validate_path_security(notes_dir, dest_dir / ".nd_probe"):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
except OSError:
|
|
|
|
|
return None
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
|
|
|
full_path = dest_dir / f"drawing-{timestamp}.png"
|
|
|
|
|
if not validate_path_security(notes_dir, full_path):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
with open(full_path, "wb") as f:
|
|
|
|
|
f.write(file_data)
|
|
|
|
|
return str(full_path.relative_to(base).as_posix())
|
|
|
|
|
except OSError as e:
|
|
|
|
|
print(f"Error saving image: {e}")
|
|
|
|
|
return None
|
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
sanitized_name = sanitize_filename(filename)
|
|
|
|
|
ext = Path(sanitized_name).suffix
|
|
|
|
|
name_without_ext = Path(sanitized_name).stem
|
2026-04-21 14:00:58 +00:00
|
|
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
2025-11-20 13:10:17 +00:00
|
|
|
final_filename = f"{name_without_ext}-{timestamp}{ext}"
|
|
|
|
|
attachments_dir = get_attachment_dir(notes_dir, note_path)
|
|
|
|
|
attachments_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
full_path = attachments_dir / final_filename
|
|
|
|
|
if not validate_path_security(notes_dir, full_path):
|
|
|
|
|
print(f"Security: Attempted to save image outside notes directory: {full_path}")
|
|
|
|
|
return None
|
|
|
|
|
try:
|
2026-04-21 14:00:58 +00:00
|
|
|
with open(full_path, "wb") as f:
|
2025-11-20 13:10:17 +00:00
|
|
|
f.write(file_data)
|
2026-04-21 14:00:58 +00:00
|
|
|
return str(full_path.relative_to(base).as_posix())
|
|
|
|
|
except OSError as e:
|
2025-11-20 13:10:17 +00:00
|
|
|
print(f"Error saving image: {e}")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
# Media file type definitions
|
|
|
|
|
MEDIA_EXTENSIONS = {
|
|
|
|
|
'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp'},
|
|
|
|
|
'audio': {'.mp3', '.wav', '.ogg', '.m4a'},
|
|
|
|
|
'video': {'.mp4', '.webm', '.mov', '.avi'},
|
|
|
|
|
'document': {'.pdf'},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# All supported media extensions (flat set for quick lookup)
|
|
|
|
|
ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_media_type(filename: str) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
Determine the media type based on file extension.
|
2026-04-21 13:22:09 +00:00
|
|
|
Returns: 'image', 'audio', 'video', 'document', 'drawing', or None if not a media file.
|
|
|
|
|
|
|
|
|
|
Drawings are PNG files stored like images but named drawing-*.png (editable canvas in the app).
|
2026-01-18 15:00:01 +00:00
|
|
|
"""
|
2026-04-21 13:22:09 +00:00
|
|
|
name_lower = Path(filename).name.lower()
|
|
|
|
|
if name_lower.startswith('drawing-') and name_lower.endswith('.png'):
|
|
|
|
|
return 'drawing'
|
2026-01-18 15:00:01 +00:00
|
|
|
ext = Path(filename).suffix.lower()
|
|
|
|
|
for media_type, extensions in MEDIA_EXTENSIONS.items():
|
|
|
|
|
if ext in extensions:
|
|
|
|
|
return media_type
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
def parse_tags(content: str) -> List[str]:
|
|
|
|
|
"""
|
|
|
|
|
Extract tags from YAML frontmatter in markdown content.
|
|
|
|
|
|
|
|
|
|
Supported formats:
|
|
|
|
|
---
|
|
|
|
|
tags: [python, tutorial, backend]
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
or
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
tags:
|
|
|
|
|
- python
|
|
|
|
|
- tutorial
|
|
|
|
|
- backend
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
content: Markdown content with optional YAML frontmatter
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of tag strings (lowercase, no duplicates)
|
|
|
|
|
"""
|
|
|
|
|
tags = []
|
|
|
|
|
|
|
|
|
|
# Check if content starts with frontmatter
|
|
|
|
|
if not content.strip().startswith('---'):
|
|
|
|
|
return tags
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Extract frontmatter (between first two --- markers)
|
|
|
|
|
lines = content.split('\n')
|
|
|
|
|
if lines[0].strip() != '---':
|
|
|
|
|
return tags
|
|
|
|
|
|
|
|
|
|
# Find closing ---
|
|
|
|
|
end_idx = None
|
|
|
|
|
for i in range(1, len(lines)):
|
|
|
|
|
if lines[i].strip() == '---':
|
|
|
|
|
end_idx = i
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if end_idx is None:
|
|
|
|
|
return tags
|
|
|
|
|
|
|
|
|
|
frontmatter_lines = lines[1:end_idx]
|
|
|
|
|
|
|
|
|
|
# Parse tags field
|
|
|
|
|
in_tags_list = False
|
|
|
|
|
for line in frontmatter_lines:
|
|
|
|
|
stripped = line.strip()
|
|
|
|
|
|
|
|
|
|
# Check for inline array format: tags: [tag1, tag2, tag3]
|
|
|
|
|
if stripped.startswith('tags:'):
|
|
|
|
|
rest = stripped[5:].strip()
|
|
|
|
|
if rest.startswith('[') and rest.endswith(']'):
|
|
|
|
|
# Parse inline array
|
|
|
|
|
tags_str = rest[1:-1] # Remove [ and ]
|
|
|
|
|
raw_tags = [t.strip() for t in tags_str.split(',')]
|
|
|
|
|
tags.extend([t.lower() for t in raw_tags if t])
|
|
|
|
|
break
|
|
|
|
|
elif rest:
|
|
|
|
|
# Single tag without brackets
|
|
|
|
|
tags.append(rest.lower())
|
|
|
|
|
break
|
|
|
|
|
else:
|
|
|
|
|
# Multi-line list format
|
|
|
|
|
in_tags_list = True
|
|
|
|
|
elif in_tags_list:
|
|
|
|
|
if stripped.startswith('-'):
|
|
|
|
|
# List item
|
|
|
|
|
tag = stripped[1:].strip()
|
|
|
|
|
if tag:
|
|
|
|
|
tags.append(tag.lower())
|
|
|
|
|
elif stripped and not stripped.startswith('#'):
|
|
|
|
|
# End of tags list
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Remove duplicates and return
|
|
|
|
|
return sorted(list(set(tags)))
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
# If parsing fails, return empty list
|
|
|
|
|
print(f"Error parsing tags: {e}")
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_all_tags(notes_dir: str) -> Dict[str, int]:
|
2026-06-30 09:59:25 +00:00
|
|
|
"""All tags in the vault with note counts."""
|
2026-06-30 11:47:19 +00:00
|
|
|
ensure_index_built(notes_dir)
|
2026-06-30 09:59:25 +00:00
|
|
|
return note_index.get_all_tags()
|
2025-11-24 16:11:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
|
2026-06-30 09:59:25 +00:00
|
|
|
"""All notes carrying `tag` (case-insensitive)."""
|
2026-06-30 11:47:19 +00:00
|
|
|
ensure_index_built(notes_dir)
|
2026-06-30 09:59:25 +00:00
|
|
|
idx = note_index.get_index()
|
|
|
|
|
records = [idx.get_note_record(p) for p in note_index.get_paths_for_tag(tag.lower())]
|
|
|
|
|
matching = [
|
|
|
|
|
{
|
|
|
|
|
"name": r.name,
|
|
|
|
|
"path": r.path,
|
|
|
|
|
"folder": r.folder,
|
|
|
|
|
"modified": r.modified,
|
|
|
|
|
"size": r.size,
|
|
|
|
|
"tags": list(r.tags),
|
|
|
|
|
}
|
|
|
|
|
for r in records
|
|
|
|
|
if r is not None and r.type == "note"
|
|
|
|
|
]
|
|
|
|
|
matching.sort(key=lambda n: n["modified"], reverse=True)
|
2026-06-30 09:14:42 +00:00
|
|
|
return matching
|
2025-11-24 16:11:22 +00:00
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Template Functions
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def get_templates(notes_dir: str) -> List[Dict]:
|
|
|
|
|
"""
|
|
|
|
|
Get all templates from the _templates folder.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
notes_dir: Base notes directory
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of template metadata (name, path, modified)
|
|
|
|
|
"""
|
|
|
|
|
templates = []
|
|
|
|
|
templates_path = Path(notes_dir) / "_templates"
|
|
|
|
|
|
|
|
|
|
if not templates_path.exists():
|
|
|
|
|
return templates
|
|
|
|
|
|
2025-11-26 11:01:50 +00:00
|
|
|
# Security check: ensure _templates folder is within notes directory
|
|
|
|
|
if not validate_path_security(notes_dir, templates_path):
|
|
|
|
|
print(f"Security: Templates directory is outside notes directory: {templates_path}")
|
|
|
|
|
return templates
|
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
try:
|
|
|
|
|
for template_file in templates_path.glob("*.md"):
|
|
|
|
|
try:
|
2025-11-26 11:01:50 +00:00
|
|
|
# Security check: ensure each template is within notes directory
|
|
|
|
|
if not validate_path_security(notes_dir, template_file):
|
|
|
|
|
print(f"Security: Skipping template outside notes directory: {template_file}")
|
|
|
|
|
continue
|
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
stat = template_file.stat()
|
|
|
|
|
templates.append({
|
|
|
|
|
"name": template_file.stem,
|
|
|
|
|
"path": str(template_file.relative_to(notes_dir).as_posix()),
|
2025-12-12 16:35:04 +00:00
|
|
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
2025-11-26 10:28:09 +00:00
|
|
|
})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error reading template {template_file}: {e}")
|
|
|
|
|
continue
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error accessing templates directory: {e}")
|
|
|
|
|
|
|
|
|
|
return sorted(templates, key=lambda x: x['name'])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_template_content(notes_dir: str, template_name: str) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
Get the content of a specific template.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
notes_dir: Base notes directory
|
|
|
|
|
template_name: Name of the template (without .md extension)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Template content or None if not found
|
|
|
|
|
"""
|
|
|
|
|
template_path = Path(notes_dir) / "_templates" / f"{template_name}.md"
|
|
|
|
|
|
|
|
|
|
if not template_path.exists():
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Security check: ensure template is within notes directory
|
|
|
|
|
if not validate_path_security(notes_dir, template_path):
|
|
|
|
|
print(f"Security: Template path is outside notes directory: {template_path}")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with open(template_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
return f.read()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error reading template {template_name}: {e}")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 14:06:03 +00:00
|
|
|
# Matches custom strftime placeholders like {{date:%Y%m%d}} or {{time:%H%M%S}}.
|
|
|
|
|
# The three prefixes mirror the bare {{date}}, {{time}}, {{datetime}} placeholders;
|
|
|
|
|
# disallowing '{' and '}' inside the format keeps parsing unambiguous and
|
|
|
|
|
# guarantees we never swallow an adjacent placeholder.
|
|
|
|
|
_STRFTIME_PLACEHOLDER_RE = re.compile(r'\{\{(date|time|datetime):([^{}]+)\}\}')
|
|
|
|
|
|
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
def apply_template_placeholders(content: str, note_path: str) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Replace template placeholders with actual values.
|
2026-05-06 14:06:03 +00:00
|
|
|
|
|
|
|
|
Built-in named placeholders (fixed default formats):
|
2025-12-16 15:13:24 +00:00
|
|
|
{{date}} - Current date (YYYY-MM-DD)
|
|
|
|
|
{{time}} - Current time (HH:MM:SS)
|
|
|
|
|
{{datetime}} - Current datetime (YYYY-MM-DD HH:MM:SS)
|
2026-05-06 14:06:03 +00:00
|
|
|
{{timestamp}} - Unix timestamp (seconds)
|
2025-12-16 15:13:24 +00:00
|
|
|
{{year}} - Current year (YYYY)
|
|
|
|
|
{{month}} - Current month (MM)
|
|
|
|
|
{{day}} - Current day (DD)
|
|
|
|
|
{{title}} - Note name without extension
|
|
|
|
|
{{folder}} - Parent folder name
|
2026-05-06 14:06:03 +00:00
|
|
|
|
|
|
|
|
Custom date/time formats (strftime escape hatch):
|
|
|
|
|
{{date:FMT}}, {{time:FMT}}, {{datetime:FMT}}
|
|
|
|
|
|
|
|
|
|
Any of the three date/time placeholders above also accepts an
|
|
|
|
|
optional ":FMT" suffix where FMT is a Python strftime() format
|
|
|
|
|
string. Pick the prefix whose default format covers the same
|
|
|
|
|
components you want to format. Examples:
|
|
|
|
|
|
|
|
|
|
{{datetime:%Y%m%d%H%M%S}} -> 20260506154200 (filename-safe stamp)
|
|
|
|
|
{{date:%d/%m/%Y}} -> 06/05/2026 (European date)
|
|
|
|
|
{{date:%A}} -> Wednesday (full weekday name)
|
|
|
|
|
{{time:%H%M%S}} -> 154200 (compact time)
|
|
|
|
|
{{date:%V}} -> 19 (ISO week number)
|
|
|
|
|
|
|
|
|
|
Invalid format strings are left in the output unchanged so a typo
|
|
|
|
|
is visible rather than silently swallowed.
|
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
Args:
|
|
|
|
|
content: Template content with placeholders
|
|
|
|
|
note_path: Path of the note being created
|
2026-05-06 14:06:03 +00:00
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
Returns:
|
|
|
|
|
Content with placeholders replaced
|
|
|
|
|
"""
|
|
|
|
|
now = datetime.now()
|
|
|
|
|
note = Path(note_path)
|
2026-05-06 14:06:03 +00:00
|
|
|
|
|
|
|
|
def _strftime_sub(match: 're.Match[str]') -> str:
|
|
|
|
|
fmt = match.group(2)
|
|
|
|
|
try:
|
|
|
|
|
return now.strftime(fmt)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
|
|
|
|
content = _STRFTIME_PLACEHOLDER_RE.sub(_strftime_sub, content)
|
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
replacements = {
|
|
|
|
|
'{{date}}': now.strftime('%Y-%m-%d'),
|
|
|
|
|
'{{time}}': now.strftime('%H:%M:%S'),
|
|
|
|
|
'{{datetime}}': now.strftime('%Y-%m-%d %H:%M:%S'),
|
|
|
|
|
'{{timestamp}}': str(int(now.timestamp())),
|
2025-12-16 15:13:24 +00:00
|
|
|
'{{year}}': now.strftime('%Y'),
|
|
|
|
|
'{{month}}': now.strftime('%m'),
|
|
|
|
|
'{{day}}': now.strftime('%d'),
|
2025-11-26 10:28:09 +00:00
|
|
|
'{{title}}': note.stem,
|
|
|
|
|
'{{folder}}': note.parent.name if str(note.parent) != '.' else 'Root',
|
|
|
|
|
}
|
2026-05-06 14:06:03 +00:00
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
result = content
|
|
|
|
|
for placeholder, value in replacements.items():
|
|
|
|
|
result = result.replace(placeholder, value)
|
2026-05-06 14:06:03 +00:00
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
return result
|
|
|
|
|
|
2026-03-24 09:58:32 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
def _extract_backlink_references(
|
|
|
|
|
notes_dir: str,
|
|
|
|
|
source_path: str,
|
|
|
|
|
target_path: str,
|
|
|
|
|
target_path_lower: str,
|
|
|
|
|
target_path_no_ext_lower: str,
|
|
|
|
|
wikilink_refs: set,
|
|
|
|
|
) -> List[Dict]:
|
|
|
|
|
"""Read one source file and pull line-level references that point to target.
|
|
|
|
|
|
|
|
|
|
Same regex + resolution rules as the legacy get_backlinks loop body; just
|
|
|
|
|
factored out so both the index-driven and the legacy code paths use the
|
|
|
|
|
exact same context-extraction logic.
|
2026-03-24 09:58:32 +00:00
|
|
|
"""
|
2026-06-30 09:14:42 +00:00
|
|
|
source_folder = str(Path(source_path).parent).replace('\\', '/')
|
|
|
|
|
if source_folder == '.':
|
|
|
|
|
source_folder = ''
|
2026-03-24 12:23:47 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
full_path = Path(notes_dir) / source_path
|
|
|
|
|
try:
|
|
|
|
|
with open(full_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
content = f.read()
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
2026-03-24 12:23:47 +00:00
|
|
|
|
2026-06-30 09:14:42 +00:00
|
|
|
lines = content.split('\n')
|
|
|
|
|
found_links: List[Dict] = []
|
|
|
|
|
|
|
|
|
|
for line_num, line in enumerate(lines, 1):
|
|
|
|
|
for match in re.finditer(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', line):
|
|
|
|
|
link_target = match.group(1).strip().lower()
|
|
|
|
|
link_target_no_ext = link_target.replace('.md', '')
|
|
|
|
|
if link_target in wikilink_refs or link_target_no_ext in wikilink_refs:
|
|
|
|
|
start = max(0, match.start() - 30)
|
|
|
|
|
end = min(len(line), match.end() + 30)
|
|
|
|
|
context = line[start:end]
|
|
|
|
|
if start > 0:
|
|
|
|
|
context = '...' + context
|
|
|
|
|
if end < len(line):
|
|
|
|
|
context = context + '...'
|
|
|
|
|
found_links.append({
|
|
|
|
|
"line_number": line_num,
|
|
|
|
|
"context": context,
|
|
|
|
|
"type": "wikilink",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for match in re.finditer(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', line):
|
|
|
|
|
link_path = match.group(2).split('#')[0]
|
|
|
|
|
if not link_path:
|
|
|
|
|
continue
|
|
|
|
|
link_path = urllib.parse.unquote(link_path)
|
|
|
|
|
if link_path.startswith('./'):
|
|
|
|
|
link_path = link_path[2:]
|
|
|
|
|
link_path_with_md = link_path if link_path.endswith('.md') else link_path + '.md'
|
|
|
|
|
|
|
|
|
|
resolved_path = None
|
|
|
|
|
if source_folder and not link_path.startswith('/'):
|
|
|
|
|
relative_path = f"{source_folder}/{link_path_with_md}"
|
|
|
|
|
if relative_path.lower() == target_path_lower:
|
|
|
|
|
resolved_path = target_path
|
|
|
|
|
elif f"{source_folder}/{link_path}".lower() == target_path_no_ext_lower:
|
|
|
|
|
resolved_path = target_path
|
|
|
|
|
if not resolved_path:
|
|
|
|
|
if link_path_with_md.lower() == target_path_lower:
|
|
|
|
|
resolved_path = target_path
|
|
|
|
|
elif link_path.lower() == target_path_no_ext_lower:
|
|
|
|
|
resolved_path = target_path
|
|
|
|
|
|
|
|
|
|
if resolved_path:
|
|
|
|
|
start = max(0, match.start() - 30)
|
|
|
|
|
end = min(len(line), match.end() + 30)
|
|
|
|
|
context = line[start:end]
|
|
|
|
|
if start > 0:
|
|
|
|
|
context = '...' + context
|
|
|
|
|
if end < len(line):
|
|
|
|
|
context = context + '...'
|
|
|
|
|
found_links.append({
|
|
|
|
|
"line_number": line_num,
|
|
|
|
|
"context": context,
|
|
|
|
|
"type": "markdown",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return found_links
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
|
2026-06-30 09:59:25 +00:00
|
|
|
"""All notes that link TO `target_note_path`. The index narrows the
|
|
|
|
|
candidate set; we only read the candidate files for line context."""
|
2026-03-24 09:58:32 +00:00
|
|
|
target_path = target_note_path
|
2026-03-24 12:23:47 +00:00
|
|
|
target_path_lower = target_path.lower()
|
2026-06-30 09:14:42 +00:00
|
|
|
target_path_no_ext_lower = target_path_lower.replace('.md', '')
|
2026-03-24 12:23:47 +00:00
|
|
|
wikilink_refs = {
|
|
|
|
|
target_path_lower,
|
|
|
|
|
target_path_no_ext_lower,
|
2026-06-30 09:14:42 +00:00
|
|
|
Path(target_path).stem.lower(),
|
2026-03-24 09:58:32 +00:00
|
|
|
}
|
2026-06-30 09:14:42 +00:00
|
|
|
|
2026-06-30 11:47:19 +00:00
|
|
|
ensure_index_built(notes_dir)
|
2026-06-30 09:59:25 +00:00
|
|
|
idx = note_index.get_index()
|
|
|
|
|
candidates = note_index.get_backlink_candidates(target_path)
|
|
|
|
|
records = [(p, idx.get_note_record(p)) for p in candidates]
|
|
|
|
|
records = [(p, r) for (p, r) in records if r is not None and r.type == "note" and p != target_path]
|
|
|
|
|
records.sort(key=lambda pr: pr[1].mtime, reverse=True)
|
2026-06-30 09:14:42 +00:00
|
|
|
|
|
|
|
|
backlinks: List[Dict] = []
|
2026-06-30 09:59:25 +00:00
|
|
|
for source_path, record in records:
|
2026-06-30 09:14:42 +00:00
|
|
|
refs = _extract_backlink_references(
|
|
|
|
|
notes_dir,
|
|
|
|
|
source_path,
|
|
|
|
|
target_path,
|
|
|
|
|
target_path_lower,
|
|
|
|
|
target_path_no_ext_lower,
|
|
|
|
|
wikilink_refs,
|
|
|
|
|
)
|
|
|
|
|
if refs:
|
2026-03-24 09:58:32 +00:00
|
|
|
backlinks.append({
|
|
|
|
|
"path": source_path,
|
2026-06-30 09:59:25 +00:00
|
|
|
"name": record.name.replace('.md', ''),
|
2026-06-30 09:14:42 +00:00
|
|
|
"references": refs[:3],
|
2026-03-24 09:58:32 +00:00
|
|
|
})
|
|
|
|
|
return backlinks
|
|
|
|
|
|