feat: roadmap items #7 #8 #9 #10 — recent files, semver tag, search filters, dedup

- #7: Section « Récents » dans la page d'accueil vault (5 derniers fichiers)
- #8: Git tag v1.8.0
- #9: Filtres search avancés created:/modified:/size: avec parsing dates/sizes
- #10: Déduplication IGNORED_DIRS (indexer.py source unique, watcher+main importent)
- ROADMAP: tous les items marqués FAIT, version → 1.9.0
This commit is contained in:
2026-06-04 12:11:51 -04:00
parent fc1a4e336e
commit 0b3a7d5c95
7 changed files with 353 additions and 30 deletions
+165
View File
@@ -951,6 +951,146 @@ def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[st
return True
# ---------------------------------------------------------------------------
# Date and size filter helpers
# ---------------------------------------------------------------------------
def _parse_date_range(raw: Optional[str]) -> Optional[tuple]:
"""Parse a date range filter string.
Supported formats:
- ">2024-01-01" → after (inclusive)
- "<2024-06-01" → before (inclusive)
- "2024-01-01..2024-06-01" → between (inclusive)
- "<7d" → within last 7 days
- ">30d" → older than 30 days
- "YYYY-MM-DD" → exact date match (created/modified on that day)
Returns (min_ts, max_ts) in Unix timestamps, or None if unparseable.
"""
if not raw:
return None
raw = raw.strip()
# Relative: <7d, >30d
rel_match = re.match(r'^([<>])(\d+)([dh])$', raw)
if rel_match:
op, num, unit = rel_match.groups()
n = int(num)
seconds = n * 3600 if unit == 'h' else n * 86400
now = time.time()
if op == '<':
return (now - seconds, now)
else:
return (0, now - seconds)
# Range: date1..date2
if '..' in raw:
parts = raw.split('..', 1)
t1 = _parse_date_to_ts(parts[0].strip())
t2 = _parse_date_to_ts(parts[1].strip())
if t1 is not None and t2 is not None:
return (min(t1, t2), max(t1, t2))
return None
# Comparator: >date, <date
if raw.startswith('>'):
ts = _parse_date_to_ts(raw[1:].strip())
return (ts, 9999999999) if ts is not None else None
if raw.startswith('<'):
ts = _parse_date_to_ts(raw[1:].strip())
return (0, ts) if ts is not None else None
# Exact date
ts = _parse_date_to_ts(raw)
if ts is not None:
return (ts, ts + 86400) # whole day
return None
def _parse_date_to_ts(s: str) -> Optional[float]:
"""Parse a date string to Unix timestamp. Supports YYYY-MM-DD."""
s = s.strip()
for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y'):
try:
from datetime import datetime as dt_mod
return dt_mod.strptime(s, fmt).timestamp()
except ValueError:
continue
return None
def _matches_date_range(file_ts: Optional[float], date_range: tuple) -> bool:
"""Check if a file timestamp falls within the given range."""
if file_ts is None:
return False
return date_range[0] <= file_ts <= date_range[1]
def _parse_size_range(raw: Optional[str]) -> Optional[tuple]:
"""Parse a size range filter string.
Supported formats:
- ">1MB", ">500KB", ">1GB" → larger than
- "<100KB" → smaller than
- "500KB..2MB" → between
Returns (min_bytes, max_bytes), or None if unparseable.
"""
if not raw:
return None
raw = raw.strip()
def _parse_size(s: str) -> Optional[int]:
s = s.strip().upper()
mult = 1
if s.endswith('GB'):
mult = 1024 * 1024 * 1024
s = s[:-2]
elif s.endswith('MB'):
mult = 1024 * 1024
s = s[:-2]
elif s.endswith('KB'):
mult = 1024
s = s[:-2]
elif s.endswith('B'):
mult = 1
s = s[:-1]
elif s.endswith('O'):
mult = 1
s = s[:-1]
try:
return int(float(s) * mult)
except (ValueError, TypeError):
return None
if '..' in raw:
parts = raw.split('..', 1)
lo = _parse_size(parts[0])
hi = _parse_size(parts[1])
if lo is not None and hi is not None:
return (min(lo, hi), max(lo, hi))
return None
if raw.startswith('>'):
lo = _parse_size(raw[1:])
return (lo, 10**15) if lo is not None else None
if raw.startswith('<'):
hi = _parse_size(raw[1:])
return (0, hi) if hi is not None else None
# Exact: try parsing as a number (bytes) or with unit
exact = _parse_size(raw)
if exact is not None:
return (exact, exact * 2) # fuzzy match
return None
def _matches_size_range(file_size: int, size_range: tuple) -> bool:
"""Check if a file size falls within the given range."""
return size_range[0] <= file_size <= size_range[1]
def advanced_search(
query: str,
vault_filter: str = "all",
@@ -963,6 +1103,9 @@ def advanced_search(
regex: bool = False,
include_paths: Optional[str] = None,
exclude_paths: Optional[str] = None,
created: Optional[str] = None,
modified: Optional[str] = None,
size: Optional[str] = None,
) -> Dict[str, Any]:
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
@@ -1074,6 +1217,28 @@ def advanced_search(
)
}
# Date and size filters (from query operators or API params)
date_range_created = _parse_date_range(created or parsed.get("created"))
if date_range_created:
candidates = {
dk for dk in candidates
if _matches_date_range(inv.doc_info[dk].get("created"), date_range_created)
}
date_range_modified = _parse_date_range(modified or parsed.get("modified"))
if date_range_modified:
candidates = {
dk for dk in candidates
if _matches_date_range(inv.doc_info[dk].get("modified"), date_range_modified)
}
size_range = _parse_size_range(size or parsed.get("size"))
if size_range:
candidates = {
dk for dk in candidates
if _matches_size_range(inv.doc_info[dk].get("size", 0), size_range)
}
# ------------------------------------------------------------------
# Step 3: Score only the candidates (not all N documents)
# ------------------------------------------------------------------