feat: vault DIR — exclusions par vault et lecture seule déclarable (vault Spark)
This commit is contained in:
+44
-4
@@ -97,6 +97,13 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
Also reads optional configuration:
|
||||
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
|
||||
- VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning
|
||||
- DIR_N_READONLY: "true"/"false" — vault DIR déclaré en lecture seule
|
||||
(les mutations sont refusées en 403, l'UI masque les actions d'écriture)
|
||||
- DIR_N_IGNORED_DIRS: noms de dossiers ignorés pour ce vault, séparés
|
||||
par des virgules (toute profondeur, en plus du global IGNORED_DIRS)
|
||||
- DIR_N_IGNORED_PATHS: motifs fnmatch sur le chemin relatif POSIX dans
|
||||
le vault, séparés par des virgules (un dossier qui matche cache tout
|
||||
son sous-arbre) — navigation, lecture et indexation les respectent
|
||||
|
||||
Returns:
|
||||
Dict mapping vault names to configuration dicts with keys:
|
||||
@@ -104,6 +111,9 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
- attachmentsPath: relative attachments folder (optional)
|
||||
- scanAttachmentsOnStartup: boolean (default True)
|
||||
- type: "VAULT" or "DIR"
|
||||
- readonly: boolean (DIR only, default False)
|
||||
- ignoredDirs: set of directory names (DIR only, default empty)
|
||||
- ignoredPaths: list of fnmatch patterns (DIR only, default empty)
|
||||
"""
|
||||
vaults: dict[str, dict[str, Any]] = {}
|
||||
n = 1
|
||||
@@ -131,12 +141,23 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
path = os.environ.get(f"DIR_{n}_PATH")
|
||||
if not name or not path:
|
||||
break
|
||||
|
||||
|
||||
readonly = os.environ.get(f"DIR_{n}_READONLY", "false").lower() == "true"
|
||||
ignored_dirs = {
|
||||
d.strip() for d in os.environ.get(f"DIR_{n}_IGNORED_DIRS", "").split(",") if d.strip()
|
||||
}
|
||||
ignored_paths = [
|
||||
p.strip() for p in os.environ.get(f"DIR_{n}_IGNORED_PATHS", "").split(",") if p.strip()
|
||||
]
|
||||
|
||||
vaults[name] = {
|
||||
"path": path,
|
||||
"attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": False,
|
||||
"type": "DIR"
|
||||
"type": "DIR",
|
||||
"readonly": readonly,
|
||||
"ignoredDirs": ignored_dirs,
|
||||
"ignoredPaths": ignored_paths,
|
||||
}
|
||||
n += 1
|
||||
|
||||
@@ -509,7 +530,8 @@ def _scan_vault(
|
||||
Args:
|
||||
vault_name: Display name of the vault.
|
||||
vault_path: Absolute filesystem path to the vault root.
|
||||
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
|
||||
vault_cfg: Optional vault configuration dict — ses exclusions
|
||||
(``ignoredDirs`` / ``ignoredPaths``) sont appliquées au scan.
|
||||
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
|
||||
previous scan used for differential reuse.
|
||||
|
||||
@@ -529,6 +551,16 @@ def _scan_vault(
|
||||
|
||||
root_resolved = vault_root.resolve(strict=False)
|
||||
|
||||
# Exclusions propres au vault (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) :
|
||||
# elles s'ajoutent au global IGNORED_DIRS, sans le modifier. Le cfg est
|
||||
# celui passé par l'appelant — l'index global ne contient pas encore ce
|
||||
# vault pendant son premier scan, chemin_exclu() (qui lit l'index) ne
|
||||
# peut donc pas servir ici.
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
cfg = vault_cfg or {}
|
||||
ignored_names = IGNORED_DIRS | set(cfg.get("ignoredDirs") or [])
|
||||
|
||||
# BUG-032: walk without following symlinks and refuse any symlink that
|
||||
# escapes the vault root, so external data can never be indexed/exposed.
|
||||
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
|
||||
@@ -537,7 +569,11 @@ def _scan_vault(
|
||||
# Prune ignored and symlinked directories in place (no recursion).
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in IGNORED_DIRS and not (current_dir / d).is_symlink()
|
||||
if d not in ignored_names
|
||||
and not (current_dir / d).is_symlink()
|
||||
and not chemin_exclu_dans_config(
|
||||
cfg, str((current_dir / d).relative_to(vault_root)).replace("\\", "/")
|
||||
)
|
||||
]
|
||||
|
||||
for d in dirnames:
|
||||
@@ -565,6 +601,10 @@ def _scan_vault(
|
||||
|
||||
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
# Exclusion propre au vault (motif de chemin, ex. un .env ciblé).
|
||||
if chemin_exclu_dans_config(cfg, rel_path_str):
|
||||
continue
|
||||
|
||||
ext = fpath.suffix.lower()
|
||||
# Also match extensionless files named like Dockerfile, Makefile
|
||||
basename_lower = fpath.name.lower()
|
||||
|
||||
@@ -44,7 +44,7 @@ from backend.schemas import (
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
from backend.services.vaults import browse_directory, chemin_exclu, get_vault_root
|
||||
from backend.share import list_shares
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
@@ -171,6 +171,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
@@ -366,6 +369,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
|
||||
@@ -609,6 +609,7 @@ class VaultInfo(BaseModel):
|
||||
file_count: int = Field(description="Number of indexed files")
|
||||
tag_count: int = Field(description="Number of unique tags")
|
||||
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
||||
readonly: bool = Field(default=False, description="True when the vault is declared read-only (DIR_N_READONLY) — mutations are refused and the UI hides write actions")
|
||||
|
||||
|
||||
class BookmarkToggleRequest(BaseModel):
|
||||
|
||||
@@ -7,13 +7,30 @@ from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.services.vaults import chemin_exclu, get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.files")
|
||||
|
||||
|
||||
def _ensure_not_excluded(vault_name: str, path: str) -> None:
|
||||
"""Raise ``not_found`` (404) when *path* is excluded in this vault.
|
||||
|
||||
Un chemin exclu (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) est
|
||||
indiscernable d'un chemin inexistant : même erreur, aucun détail
|
||||
sur la règle qui l'a caché.
|
||||
"""
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
|
||||
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
|
||||
"""Return the raw text content of a vault file (no redaction)."""
|
||||
_ensure_not_excluded(vault_name, path)
|
||||
root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
@@ -56,6 +73,7 @@ def read_file_text(
|
||||
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
|
||||
error (500).
|
||||
"""
|
||||
_ensure_not_excluded(vault_name, path)
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path)
|
||||
|
||||
|
||||
@@ -69,8 +69,18 @@ def _rel(root: Path, path: Path) -> str:
|
||||
return str(path.relative_to(root)).replace("\\", "/")
|
||||
|
||||
|
||||
def _ensure_writable(root: Path) -> None:
|
||||
"""Raise ``read_only`` (403) when the vault root is not writable."""
|
||||
def _ensure_writable(root: Path, vault_name: str | None = None) -> None:
|
||||
"""Raise ``read_only`` (403) when the vault must not be mutated.
|
||||
|
||||
Two sources, same error: the vault is **declared** read-only
|
||||
(``DIR_N_READONLY``, via :func:`vault_readonly`) or its root is
|
||||
not writable on the filesystem (``:ro`` mount).
|
||||
"""
|
||||
if vault_name is not None:
|
||||
from backend.services.vaults import vault_readonly
|
||||
|
||||
if vault_readonly(vault_name):
|
||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
||||
if not os.access(root, os.W_OK):
|
||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
||||
|
||||
@@ -139,7 +149,7 @@ def create_file(
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
_validate_extension(file_path)
|
||||
|
||||
@@ -190,7 +200,7 @@ def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> d
|
||||
``already_exists`` (409) when *exist_ok* is False.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
dir_path = resolve_safe_path(root, path)
|
||||
|
||||
if dir_path.exists():
|
||||
@@ -234,7 +244,7 @@ def edit_file(
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -470,7 +480,7 @@ def edit_xlsx_cells(
|
||||
reference or value.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -592,7 +602,7 @@ def mutate_xlsx_structure(
|
||||
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1000,7 +1010,7 @@ def mutate_xlsx_style(
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1255,7 +1265,7 @@ def save_csv_cells(
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1341,7 +1351,7 @@ def append_to_file(
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1382,7 +1392,7 @@ def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]:
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
old_path = resolve_safe_path(root, path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_file():
|
||||
@@ -1424,7 +1434,7 @@ def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any
|
||||
``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
old_path = resolve_safe_path(root, path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_dir():
|
||||
@@ -1467,7 +1477,7 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
source = resolve_safe_path(root, source_path)
|
||||
if not source.exists():
|
||||
@@ -1546,7 +1556,7 @@ def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "")
|
||||
destination is inside the source directory.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
source = resolve_safe_path(root, source_path)
|
||||
if not source.exists():
|
||||
@@ -1764,7 +1774,7 @@ def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str,
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1797,7 +1807,7 @@ def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> d
|
||||
``invalid_arguments`` (400) when targeting the vault root.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
dir_path = resolve_safe_path(root, path)
|
||||
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
@@ -1855,7 +1865,7 @@ def restore_backup(
|
||||
or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
backup_dir = get_backup_dir(vault_name, path)
|
||||
@@ -1931,7 +1941,7 @@ def save_raw_file(
|
||||
Dict with ``success``, ``vault``, ``path``, and ``size``.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
_validate_extension(file_path, allow_images=True, allow_docs=allow_docs)
|
||||
|
||||
@@ -1980,7 +1990,7 @@ def batch_upload_files(
|
||||
and ``errors`` (list of error dicts).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
clean_target = (target_dir or "").strip().strip("/\\")
|
||||
uploaded: list[str] = []
|
||||
|
||||
@@ -215,8 +215,9 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import get_vault_data, path_index
|
||||
from backend.services.vaults import chemin_exclu
|
||||
|
||||
entries = path_index.get(vault, [])
|
||||
entries = [e for e in path_index.get(vault, []) if not chemin_exclu(vault, e["path"])]
|
||||
meta: dict[str, dict[str, Any]] = {}
|
||||
data = get_vault_data(vault)
|
||||
if data:
|
||||
|
||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,6 +16,68 @@ from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
|
||||
|
||||
def chemin_exclu_dans_config(config: dict[str, Any] | None, rel_path: str) -> bool:
|
||||
"""True si *rel_path* est exclu par la config d'exclusions d'un vault.
|
||||
|
||||
Deux mécanismes, lus dans le dict ``config`` du vault (alimenté par
|
||||
``DIR_N_IGNORED_DIRS`` / ``DIR_N_IGNORED_PATHS``, vaults DIR seulement) :
|
||||
|
||||
- ``ignoredDirs`` : le nom d'un segment du chemin (à toute profondeur)
|
||||
figure dans l'ensemble — ex. ``.home`` cache ``a/.home/x`` ;
|
||||
- ``ignoredPaths`` : motifs fnmatch testés contre le chemin relatif
|
||||
POSIX **et chacun de ses préfixes** — un dossier qui matche cache
|
||||
donc tout son sous-arbre, ex. ``Executeurs/*/data`` cache aussi
|
||||
``Executeurs/Hermes/data/config.md``.
|
||||
|
||||
Tolérant : config absente/vide ou chemin vide → False.
|
||||
"""
|
||||
if not config or not rel_path:
|
||||
return False
|
||||
rel = str(rel_path).replace("\\", "/").strip("/")
|
||||
if not rel:
|
||||
return False
|
||||
segments = rel.split("/")
|
||||
ignored_dirs = config.get("ignoredDirs") or []
|
||||
if any(seg in ignored_dirs for seg in segments):
|
||||
return True
|
||||
prefixes = ["/".join(segments[:i]) for i in range(1, len(segments) + 1)]
|
||||
for pattern in config.get("ignoredPaths") or []:
|
||||
pat = str(pattern).replace("\\", "/").strip("/")
|
||||
if pat and any(fnmatch.fnmatchcase(prefix, pat) for prefix in prefixes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def chemin_exclu(vault_name: str, rel_path: str) -> bool:
|
||||
"""True si *rel_path* est exclu dans le vault *vault_name*.
|
||||
|
||||
Lit la config du vault via l'index (``get_vault_data``). Tolérant :
|
||||
vault inconnu ou sans config d'exclusions → False.
|
||||
"""
|
||||
try:
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
except Exception:
|
||||
return False
|
||||
if not data:
|
||||
return False
|
||||
return chemin_exclu_dans_config(data.get("config") or {}, rel_path)
|
||||
|
||||
|
||||
def vault_readonly(vault_name: str) -> bool:
|
||||
"""True si le vault est déclaré en lecture seule (DIR_N_READONLY)."""
|
||||
try:
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
except Exception:
|
||||
return False
|
||||
if not data:
|
||||
return False
|
||||
return bool((data.get("config") or {}).get("readonly", False))
|
||||
|
||||
|
||||
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the vaults *user* may access, with summary metadata."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
@@ -29,6 +92,7 @@ def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"file_count": len(data.get("files", [])),
|
||||
"tag_count": len(data.get("tags", {})),
|
||||
"type": data.get("config", {}).get("type", "VAULT"),
|
||||
"readonly": bool(data.get("config", {}).get("readonly", False)),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -58,7 +122,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path) if path else root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
if not target.exists() or (path and chemin_exclu(vault_name, path)):
|
||||
raise ServiceError(
|
||||
f"Path not found: {path}",
|
||||
code="not_found",
|
||||
@@ -74,12 +138,15 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(root)).replace("\\", "/")
|
||||
if chemin_exclu(vault_name, rel):
|
||||
continue
|
||||
if entry.is_dir():
|
||||
# Count only direct children (files and subdirs) for performance.
|
||||
try:
|
||||
file_count = sum(
|
||||
1 for child in entry.iterdir()
|
||||
if (not hide_hidden or not child.name.startswith("."))
|
||||
and not chemin_exclu(vault_name, f"{rel}/{child.name}")
|
||||
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
|
||||
or child.is_dir())
|
||||
)
|
||||
@@ -200,6 +267,9 @@ def list_all_files(
|
||||
stat = entry.stat()
|
||||
rel_path = str(entry.relative_to(root)).replace("\\", "/")
|
||||
|
||||
if chemin_exclu(vault_name, rel_path):
|
||||
continue
|
||||
|
||||
if recursive and dir_prefix:
|
||||
try:
|
||||
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
|
||||
|
||||
Reference in New Issue
Block a user