From 0b3a7d5c95b8a6906d3e4e7ae410b774659f6072 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 4 Jun 2026 12:11:51 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20roadmap=20items=20#7=20#8=20#9=20#10=20?= =?UTF-8?q?=E2=80=94=20recent=20files,=20semver=20tag,=20search=20filters,?= =?UTF-8?q?=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- ROADMAP.md | 30 ++++--- backend/main.py | 15 +++- backend/search.py | 165 +++++++++++++++++++++++++++++++++++++++ backend/watcher.py | 17 +--- frontend/js/search.js | 24 +++++- frontend/js/vaulthome.js | 61 +++++++++++++++ frontend/style.css | 71 +++++++++++++++++ 7 files changed, 353 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6cc88fa..cf721ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 1.8.0 | **Dernière mise à jour :** 2026-06-03 +> **Version :** 1.9.0 | **Dernière mise à jour :** 2026-06-04 > Voir aussi [CHANGELOG.md](./CHANGELOG.md), [docs/ROADMAP.md](./docs/ROADMAP.md), [AUDIT_TECHNIQUE.md](./docs/AUDIT_TECHNIQUE_2026-05-27.md) --- @@ -137,21 +137,33 @@ - [x] **Menu contextuel** : clic droit sur un fichier → "Backups / Versions" - [x] **Coloration syntaxique diff** : lignes ajoutées en vert, supprimées en rouge, en-têtes hunk en accent -### 7. Fichiers récents par vault +### ✅ 7. Fichiers récents par vault — FAIT - **Effort** : 0.5 jour | **Impact** : 🟡 -- Dashboard filtré par vault actif +- Dashboard filtré par vault actif ✅ +- [x] **Section « Récents » dans la page d'accueil vault** : affiche les 5 derniers fichiers ouverts dans le vault via `/api/recent?vault=...` +- [x] **Style compact** : liste en haut de la page racine du vault, avec titre, sous-dossier, et temps relatif +- [x] **Click → preview, double-click → ouvrir** -### 8. Git tags semver +### ✅ 8. Git tags semver — FAIT - **Effort** : 10 min | **Impact** : 🟢 -- `git tag v1.7.0 && git push --tags` +- [x] `git tag v1.8.0 && git push --tags` ✅ -### 9. Filtres search avancés +### ✅ 9. Filtres search avancés — FAIT - **Effort** : 1 jour | **Impact** : 🟡 -- Opérateurs `created:`, `modified:`, `size:` +- Opérateurs `created:`, `modified:`, `size:` ✅ +- **Fichiers** : `frontend/js/search.js` (QueryParser, SearchChips, performAdvancedSearch), `backend/search.py` (helpers + filters), `backend/main.py` (endpoint params) +- [x] **`created:>2024-01-01`** / **`created:2024-01-01..2024-06-01`** — filtre par date de création +- [x] **`modified:<7d`** / **`modified:>2024-01-01`** — filtre par date de modification (relatif ou absolu) +- [x] **`size:>1MB`** / **`size:100KB..2MB`** — filtre par taille de fichier +- [x] **Chips visuels** dans la barre de recherche pour chaque opérateur actif +- [x] **Filtrage backend** via `_parse_date_range`, `_parse_size_range` avec support unités (B, KB, MB, GB) et dates relatives (7d, 30d, 24h) -### 10. Déduplication IGNORED_DIRS +### ✅ 10. Déduplication IGNORED_DIRS — FAIT - **Effort** : 0.5 jour | **Impact** : 🟢 -- `indexer.py` et `watcher.py` ont des définitions séparées → mutualiser +- `indexer.py`, `watcher.py` et `main.py` avaient des définitions séparées → mutualisé ✅ +- [x] **Source unique** : `IGNORED_DIRS` dans `backend/indexer.py` (configurable via `OBSIGATE_IGNORED_DIRS`) +- [x] **`watcher.py`** : importe `IGNORED_DIRS` depuis `backend.indexer` (supprime ~20 lignes dupliquées) +- [x] **`main.py`** : importe `IGNORED_DIRS` depuis `backend.indexer` (supprime la copie inline) ### ✅ 11. Indicateur AI Actif — FAIT - **Effort** : 0.5 jour | **Impact** : 🟡 diff --git a/backend/main.py b/backend/main.py index 2606ff6..509e273 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,6 +36,7 @@ from backend.indexer import ( parse_markdown_file, _extract_tags, SUPPORTED_EXTENSIONS, + IGNORED_DIRS, update_single_file, remove_single_file, handle_file_move, @@ -2445,6 +2446,9 @@ async def api_advanced_search( regex: bool = Query(False, description="Treat query as regex"), include_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to include"), exclude_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to exclude"), + created: Optional[str] = Query(None, description="Created date filter (>date, `` — filter by title substring - ``path:`` — filter by path substring - ``ext:`` — filter by file extension + - ``created:>2024-01-01`` — filter by creation date + - ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date + - ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size - Remaining text is scored using TF-IDF with accent normalization. - Toggles: case_sensitive, whole_word, regex - Path filters: include_paths, exclude_paths (glob patterns) @@ -2467,7 +2474,8 @@ async def api_advanced_search( partial(advanced_search, q, vault_filter=vault, tag_filter=tag, limit=limit, offset=offset, sort_by=sort, case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, - include_paths=include_paths, exclude_paths=exclude_paths), + include_paths=include_paths, exclude_paths=exclude_paths, + created=created, modified=modified, size=size), ) @@ -3146,7 +3154,6 @@ async def api_vault_recent_files( raise HTTPException(status_code=404, detail=f"Directory not found: {dir}") files = [] - ignored = {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} dir_prefix = (dir or "").strip("/") try: @@ -3156,7 +3163,7 @@ async def api_vault_recent_files( continue if entry.name.startswith('.'): continue - if entry.name in ignored: + if entry.name in IGNORED_DIRS: continue # Skip entries whose parent dir chain contains an ignored dir (for rglob) @@ -3164,7 +3171,7 @@ async def api_vault_recent_files( skip = False try: for part in entry.relative_to(dir_path).parts[:-1]: - if part.startswith('.') or part in ignored: + if part.startswith('.') or part in IGNORED_DIRS: skip = True break except ValueError: diff --git a/backend/search.py b/backend/search.py index c48693f..9a0b085 100644 --- a/backend/search.py +++ b/backend/search.py @@ -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, '): + 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) # ------------------------------------------------------------------ diff --git a/backend/watcher.py b/backend/watcher.py index 1cffa4d..1281b6c 100644 --- a/backend/watcher.py +++ b/backend/watcher.py @@ -8,25 +8,10 @@ from typing import Callable, Dict, List, Optional from watchdog.observers import Observer from watchdog.observers.polling import PollingObserver from watchdog.events import FileSystemEventHandler -from backend.indexer import SUPPORTED_EXTENSIONS +from backend.indexer import SUPPORTED_EXTENSIONS, IGNORED_DIRS logger = logging.getLogger("obsigate.watcher") -# Extensions de fichiers surveillées -# Default ignored directories (can be overridden via OBSIGATE_IGNORED_DIRS env var) -_DEFAULT_IGNORED_DIRS = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'} - -def _load_ignored_dirs() -> set: - """Load ignored directories from environment or use defaults.""" - env_val = os.environ.get("OBSIGATE_IGNORED_DIRS", "") - if env_val: - custom = set(d.strip() for d in env_val.split(",") if d.strip()) - logger.info(f"Using custom IGNORED_DIRS: {custom}") - return custom - return _DEFAULT_IGNORED_DIRS.copy() - -IGNORED_DIRS = _load_ignored_dirs() - class VaultEventHandler(FileSystemEventHandler): """Gestionnaire d'événements filesystem pour une vault Obsidian. diff --git a/frontend/js/search.js b/frontend/js/search.js index 7b2ad70..d405e21 100644 --- a/frontend/js/search.js +++ b/frontend/js/search.js @@ -56,7 +56,8 @@ export const SearchHistory = { // --------------------------------------------------------------------------- export const QueryParser = { parse(raw) { - const result = { tags: [], vault: null, title: null, path: null, ext: null, freeText: "" }; + const result = { tags: [], vault: null, title: null, path: null, ext: null, + created: null, modified: null, size: null, freeText: "" }; if (!raw) return result; const tokens = this._tokenize(raw); const freeTokens = []; @@ -75,6 +76,12 @@ export const QueryParser = { result.path = tok.slice(5).replace(/"/g, "").trim(); } else if (lower.startsWith("ext:")) { result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase(); + } else if (lower.startsWith("created:")) { + result.created = tok.slice(8).replace(/"/g, "").trim(); + } else if (lower.startsWith("modified:")) { + result.modified = tok.slice(9).replace(/"/g, "").trim(); + } else if (lower.startsWith("size:")) { + result.size = tok.slice(5).replace(/"/g, "").trim(); } else { freeTokens.push(tok); } @@ -434,6 +441,18 @@ export const SearchChips = { this._addChip("ext", `ext:${parsed.ext}`, parsed.ext); hasChips = true; } + if (parsed.created) { + this._addChip("date", `created:${parsed.created}`, `créé ${parsed.created}`); + hasChips = true; + } + if (parsed.modified) { + this._addChip("date", `modified:${parsed.modified}`, `modifié ${parsed.modified}`); + hasChips = true; + } + if (parsed.size) { + this._addChip("size", `size:${parsed.size}`, `taille ${parsed.size}`); + hasChips = true; + } this._container.hidden = !hasChips; }, clear() { @@ -762,6 +781,9 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse const excludeEl = document.getElementById("search-exclude-input"); if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`; if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`; + if (parsed.created) url += `&created=${encodeURIComponent(parsed.created)}`; + if (parsed.modified) url += `&modified=${encodeURIComponent(parsed.modified)}`; + if (parsed.size) url += `&size=${encodeURIComponent(parsed.size)}`; // Search timeout — abort if server takes too long const timeoutId = setTimeout( diff --git a/frontend/js/vaulthome.js b/frontend/js/vaulthome.js index 68a3ec7..d436a23 100644 --- a/frontend/js/vaulthome.js +++ b/frontend/js/vaulthome.js @@ -80,6 +80,11 @@ export async function showVaultHome(vaultName, dirPath = '') { `; await loadAndRender(vaultName, state.currentDir, resultsArea); + + // Also load recent files for this vault (only at root level) + if (!state.currentDir) { + loadRecentSection(vaultName, container); + } } /** @@ -285,6 +290,62 @@ async function loadAndRender(vaultName, dirPath, container) { } } +// --------------------------------------------------------------------------- +// Recent files section (loaded at vault root only) +// --------------------------------------------------------------------------- + +async function loadRecentSection(vaultName, container) { + try { + const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`); + const files = data.files || []; + if (files.length === 0) return; + + // Insert recent section BEFORE the results area + const resultsArea = document.getElementById('vault-home-results'); + const section = el('div', { class: 'vault-home-recent' }); + + const header = el('div', { class: 'vault-home-recent-header' }); + header.innerHTML = ` + + Récents`; + section.appendChild(header); + + const list = el('div', { class: 'vault-home-recent-list' }); + files.forEach((f) => { + const title = f.title || f.path.split('/').pop() || ''; + const displayTitle = title.match(/\.md$/i) ? title.replace(/\.md$/i, '') : title; + const dir = f.path.includes('/') ? f.path.split('/').slice(0, -1).join('/') + '/' : ''; + + const item = el('div', { class: 'vault-home-recent-item' }); + item.innerHTML = ` + + ${escapeHtml(displayTitle)} + ${escapeHtml(dir)} + ${escapeHtml(f.mtime_human || '')}`; + + item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path)); + item.addEventListener('dblclick', (e) => { + e.preventDefault(); + TabManager.openPersistent(f.vault, f.path); + }); + list.appendChild(item); + }); + + section.appendChild(list); + + // Insert before the results area + if (resultsArea) { + container.insertBefore(section, resultsArea); + } else { + container.appendChild(section); + } + + safeCreateIcons(); + } catch { + // Silently skip if recent files can't be loaded + } +} + // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- diff --git a/frontend/style.css b/frontend/style.css index 4786c37..ce6f505 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -4114,6 +4114,77 @@ body.resizing-v { font-size: 0.85rem; } +/* ── Recent files section on vault home ── */ +.vault-home-recent { + margin-bottom: 20px; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; +} + +.vault-home-recent-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} + +.vault-home-recent-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.vault-home-recent-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 6px; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s; + font-size: 0.82rem; +} + +.vault-home-recent-item:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.vault-home-recent-title { + color: var(--text-primary); + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vault-home-recent-dir { + color: var(--text-muted); + font-size: 0.72rem; + font-family: var(--font-mono); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vault-home-recent-time { + color: var(--text-muted); + font-size: 0.7rem; + white-space: nowrap; + flex-shrink: 0; +} + .vault-home-item { cursor: pointer; }