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
+21 -9
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # 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) > 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] **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 - [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** : 🟡 - **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** : 🟢 - **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** : 🟡 - **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** : 🟢 - **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 ### ✅ 11. Indicateur AI Actif — FAIT
- **Effort** : 0.5 jour | **Impact** : 🟡 - **Effort** : 0.5 jour | **Impact** : 🟡
+11 -4
View File
@@ -36,6 +36,7 @@ from backend.indexer import (
parse_markdown_file, parse_markdown_file,
_extract_tags, _extract_tags,
SUPPORTED_EXTENSIONS, SUPPORTED_EXTENSIONS,
IGNORED_DIRS,
update_single_file, update_single_file,
remove_single_file, remove_single_file,
handle_file_move, handle_file_move,
@@ -2445,6 +2446,9 @@ async def api_advanced_search(
regex: bool = Query(False, description="Treat query as regex"), regex: bool = Query(False, description="Treat query as regex"),
include_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to include"), 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"), exclude_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to exclude"),
created: Optional[str] = Query(None, description="Created date filter (>date, <date, date..date)"),
modified: Optional[str] = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
size: Optional[str] = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
"""Advanced full-text search with TF-IDF scoring, facets, and pagination. """Advanced full-text search with TF-IDF scoring, facets, and pagination.
@@ -2455,6 +2459,9 @@ async def api_advanced_search(
- ``title:<text>`` — filter by title substring - ``title:<text>`` — filter by title substring
- ``path:<text>`` — filter by path substring - ``path:<text>`` — filter by path substring
- ``ext:<type>`` — filter by file extension - ``ext:<type>`` — 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. - Remaining text is scored using TF-IDF with accent normalization.
- Toggles: case_sensitive, whole_word, regex - Toggles: case_sensitive, whole_word, regex
- Path filters: include_paths, exclude_paths (glob patterns) - 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, partial(advanced_search, q, vault_filter=vault, tag_filter=tag,
limit=limit, offset=offset, sort_by=sort, limit=limit, offset=offset, sort_by=sort,
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, 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}") raise HTTPException(status_code=404, detail=f"Directory not found: {dir}")
files = [] files = []
ignored = {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'}
dir_prefix = (dir or "").strip("/") dir_prefix = (dir or "").strip("/")
try: try:
@@ -3156,7 +3163,7 @@ async def api_vault_recent_files(
continue continue
if entry.name.startswith('.'): if entry.name.startswith('.'):
continue continue
if entry.name in ignored: if entry.name in IGNORED_DIRS:
continue continue
# Skip entries whose parent dir chain contains an ignored dir (for rglob) # Skip entries whose parent dir chain contains an ignored dir (for rglob)
@@ -3164,7 +3171,7 @@ async def api_vault_recent_files(
skip = False skip = False
try: try:
for part in entry.relative_to(dir_path).parts[:-1]: 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 skip = True
break break
except ValueError: except ValueError:
+165
View File
@@ -951,6 +951,146 @@ def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[st
return True 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( def advanced_search(
query: str, query: str,
vault_filter: str = "all", vault_filter: str = "all",
@@ -963,6 +1103,9 @@ def advanced_search(
regex: bool = False, regex: bool = False,
include_paths: Optional[str] = None, include_paths: Optional[str] = None,
exclude_paths: Optional[str] = None, exclude_paths: Optional[str] = None,
created: Optional[str] = None,
modified: Optional[str] = None,
size: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Advanced full-text search with TF-IDF scoring, facets, and pagination. """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) # Step 3: Score only the candidates (not all N documents)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+1 -16
View File
@@ -8,25 +8,10 @@ from typing import Callable, Dict, List, Optional
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver from watchdog.observers.polling import PollingObserver
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from backend.indexer import SUPPORTED_EXTENSIONS from backend.indexer import SUPPORTED_EXTENSIONS, IGNORED_DIRS
logger = logging.getLogger("obsigate.watcher") 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): class VaultEventHandler(FileSystemEventHandler):
"""Gestionnaire d'événements filesystem pour une vault Obsidian. """Gestionnaire d'événements filesystem pour une vault Obsidian.
+23 -1
View File
@@ -56,7 +56,8 @@ export const SearchHistory = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const QueryParser = { export const QueryParser = {
parse(raw) { 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; if (!raw) return result;
const tokens = this._tokenize(raw); const tokens = this._tokenize(raw);
const freeTokens = []; const freeTokens = [];
@@ -75,6 +76,12 @@ export const QueryParser = {
result.path = tok.slice(5).replace(/"/g, "").trim(); result.path = tok.slice(5).replace(/"/g, "").trim();
} else if (lower.startsWith("ext:")) { } else if (lower.startsWith("ext:")) {
result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase(); 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 { } else {
freeTokens.push(tok); freeTokens.push(tok);
} }
@@ -434,6 +441,18 @@ export const SearchChips = {
this._addChip("ext", `ext:${parsed.ext}`, parsed.ext); this._addChip("ext", `ext:${parsed.ext}`, parsed.ext);
hasChips = true; 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; this._container.hidden = !hasChips;
}, },
clear() { clear() {
@@ -762,6 +781,9 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
const excludeEl = document.getElementById("search-exclude-input"); const excludeEl = document.getElementById("search-exclude-input");
if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`; if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`;
if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.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 // Search timeout — abort if server takes too long
const timeoutId = setTimeout( const timeoutId = setTimeout(
+61
View File
@@ -80,6 +80,11 @@ export async function showVaultHome(vaultName, dirPath = '') {
</div>`; </div>`;
await loadAndRender(vaultName, state.currentDir, resultsArea); 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 = `
<i data-lucide="clock" style="width:14px;height:14px"></i>
<span>Récents</span>`;
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 = `
<i data-lucide="${getFileIcon ? getFileIcon(f.path) : 'file'}" style="width:14px;height:14px;opacity:0.6;flex-shrink:0"></i>
<span class="vault-home-recent-title">${escapeHtml(displayTitle)}</span>
<span class="vault-home-recent-dir">${escapeHtml(dir)}</span>
<span class="vault-home-recent-time">${escapeHtml(f.mtime_human || '')}</span>`;
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 // Utility
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+71
View File
@@ -4114,6 +4114,77 @@ body.resizing-v {
font-size: 0.85rem; 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 { .vault-home-item {
cursor: pointer; cursor: pointer;
} }