Vitrine publique (accueil, architecture, statut) + recherche plein texte
- Nouvelles pages publiques sans routeur externe (History API minimale, useRoute/RouteLink) : accueil, /architecture (état réel du projet, honnête sur les limites), /statut (télémétrie live de tripoli-edge lue directement sur /healthz et /metrics, aucune donnée simulée). - Recherche façon Spotlight dans TopBar : nouvel endpoint GET /api/search côté tripoli-api, recherche plein texte Postgres (tsvector généré + index GIN) sur le nom ET le contenu des fichiers texte extrait à l'upload (formats texte/code, borné à 5 Mio) ; extraits de résultat via ts_headline. - AuthLayout : logo cliquable vers l'accueil. Vérifié : tsc --noEmit propre, npm run build propre, cargo build -p tripoli-api propre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ddd0fbc0cb
commit
3172b3e256
15 changed files with 858 additions and 20 deletions
13
tripoli-api/migrations/0003_search.sql
Normal file
13
tripoli-api/migrations/0003_search.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Recherche plein texte façon Spotlight : sur le nom du fichier *et* sur
|
||||
-- son contenu extrait à l'upload (voir files::upload). Colonne générée
|
||||
-- STORED plutôt que calculée à la volée dans chaque requête : l'index
|
||||
-- GIN a besoin d'une colonne matérialisée pour être utilisable.
|
||||
ALTER TABLE files ADD COLUMN content_text TEXT;
|
||||
|
||||
ALTER TABLE files ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('french', coalesce(filename, '')), 'A') ||
|
||||
setweight(to_tsvector('french', coalesce(content_text, '')), 'B')
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX files_search_vector_idx ON files USING GIN (search_vector);
|
||||
|
|
@ -17,6 +17,58 @@ use crate::state::SharedState;
|
|||
|
||||
const CATEGORIES: [&str; 3] = ["file", "photo", "backup"];
|
||||
|
||||
/// Extensions traitées comme du texte indexable quand le type MIME seul
|
||||
/// ne suffit pas à trancher (navigateurs envoyant souvent
|
||||
/// "application/octet-stream" pour du code ou du markdown).
|
||||
const TEXT_EXTENSIONS: [&str; 19] = [
|
||||
"txt", "md", "markdown", "csv", "json", "yaml", "yml", "toml", "log", "rs", "ts", "tsx", "js",
|
||||
"jsx", "py", "html", "css", "sql", "sh",
|
||||
];
|
||||
|
||||
/// Bornes volontairement basses : ce serveur partage 2 vCPU / 3,7 Go de
|
||||
/// RAM avec six autres projets. Indexer un fichier de plusieurs centaines
|
||||
/// de Mo en mémoire pour en tirer du texte serait un coût disproportionné
|
||||
/// pour une recherche "façon Spotlight" sur un coffre personnel.
|
||||
const MAX_INDEXABLE_BYTES: i64 = 5 * 1024 * 1024;
|
||||
const MAX_INDEXED_CHARS: usize = 200_000;
|
||||
|
||||
fn looks_like_text(content_type: &str, filename: &str) -> bool {
|
||||
if content_type.starts_with("text/") || content_type == "application/json" {
|
||||
return true;
|
||||
}
|
||||
filename
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Extraction de texte volontairement minimale : formats texte bruts
|
||||
/// uniquement (pas de PDF/DOCX — demanderait une dépendance de parsing
|
||||
/// binaire absente de ce projet). Un fichier binaire reste cherchable
|
||||
/// par son nom seul, jamais indexé à moitié ou de façon trompeuse.
|
||||
async fn extract_indexable_text(
|
||||
path: &std::path::Path,
|
||||
content_type: &str,
|
||||
filename: &str,
|
||||
size_bytes: i64,
|
||||
) -> Option<String> {
|
||||
if !looks_like_text(content_type, filename) {
|
||||
return None;
|
||||
}
|
||||
if size_bytes <= 0 || size_bytes > MAX_INDEXABLE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let bytes = tokio::fs::read(path).await.ok()?;
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let truncated: String = text.chars().take(MAX_INDEXED_CHARS).collect();
|
||||
if truncated.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(truncated)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileView {
|
||||
pub id: Uuid,
|
||||
|
|
@ -133,9 +185,11 @@ pub async fn upload(
|
|||
out.flush().await?;
|
||||
tokio::fs::rename(&tmp_path, &final_path).await?;
|
||||
|
||||
let content_text = extract_indexable_text(&final_path, &content_type, &filename, size_bytes).await;
|
||||
|
||||
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
|
||||
"INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at",
|
||||
"INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes, content_text) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING created_at",
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(user.id)
|
||||
|
|
@ -143,6 +197,7 @@ pub async fn upload(
|
|||
.bind(&content_type)
|
||||
.bind(&category)
|
||||
.bind(size_bytes)
|
||||
.bind(&content_text)
|
||||
.fetch_one(&state.pool)
|
||||
.await?
|
||||
.get("created_at");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod config;
|
|||
mod error;
|
||||
mod files;
|
||||
mod notifications;
|
||||
mod search;
|
||||
mod state;
|
||||
mod storage;
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ async fn main() {
|
|||
post(notifications::mark_read),
|
||||
)
|
||||
.route("/api/storage", get(storage::get))
|
||||
.route("/api/search", get(search::search))
|
||||
.with_state(state);
|
||||
|
||||
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");
|
||||
|
|
|
|||
83
tripoli-api/src/search.rs
Normal file
83
tripoli-api/src/search.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::state::SharedState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub category: String,
|
||||
pub size_bytes: i64,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Extrait du contenu autour des termes trouvés (`ts_headline`).
|
||||
/// `None` quand le résultat provient uniquement du nom de fichier
|
||||
/// (pas de texte indexé, ex. une image ou un binaire).
|
||||
pub snippet: Option<String>,
|
||||
}
|
||||
|
||||
/// Recherche façon Spotlight : porte sur le nom de fichier *et* le
|
||||
/// contenu textuel extrait à l'upload (`files.content_text`), pas
|
||||
/// seulement le nom. `websearch_to_tsquery` accepte une saisie libre
|
||||
/// (mots, guillemets) sans jamais lever d'erreur de syntaxe, contrairement
|
||||
/// à `to_tsquery` — c'est ce qu'attend une barre de recherche.
|
||||
pub async fn search(
|
||||
State(state): State<SharedState>,
|
||||
user: AuthUser,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> Result<Json<Vec<SearchResult>>, AppError> {
|
||||
let q = query.q.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
Uuid,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Option<String>,
|
||||
),
|
||||
>(
|
||||
"SELECT id, filename, content_type, category, size_bytes, created_at, \
|
||||
NULLIF(ts_headline('french', coalesce(content_text, ''), websearch_to_tsquery('french', $2), \
|
||||
'MaxFragments=1, MaxWords=28, MinWords=10, ShortWord=3'), '') AS snippet \
|
||||
FROM files \
|
||||
WHERE owner_id = $1 AND search_vector @@ websearch_to_tsquery('french', $2) \
|
||||
ORDER BY ts_rank(search_vector, websearch_to_tsquery('french', $2)) DESC \
|
||||
LIMIT 20",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(q)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, filename, content_type, category, size_bytes, created_at, snippet)| SearchResult {
|
||||
id,
|
||||
filename,
|
||||
content_type,
|
||||
category,
|
||||
size_bytes,
|
||||
created_at,
|
||||
snippet,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue