Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:
- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs
Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
151 lines
4.3 KiB
Rust
151 lines
4.3 KiB
Rust
// synops-search — Fulltekstsøk i grafen.
|
|
//
|
|
// Bruker PostgreSQL sin tsvector/tsquery med norsk tekstkonfigurasjon
|
|
// for å søke i noder (title vektet A, content vektet B).
|
|
// Migration 011 setter opp search_vector-kolonnen og GIN-indeks.
|
|
//
|
|
// Output: matchende noder med relevans-score og utdrag til stdout.
|
|
// Logging: structured tracing til stderr.
|
|
//
|
|
// Miljøvariabler:
|
|
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
|
|
//
|
|
// Ref: docs/retninger/unix_filosofi.md, migrations/011_fulltext_search.sql
|
|
|
|
use clap::Parser;
|
|
use std::process;
|
|
use uuid::Uuid;
|
|
|
|
/// Fulltekstsøk i grafen (noder).
|
|
#[derive(Parser)]
|
|
#[command(name = "synops-search", about = "Fulltekstsøk i noder")]
|
|
struct Cli {
|
|
/// Søkestreng (fritekst)
|
|
query: String,
|
|
|
|
/// Filtrer på node_kind (f.eks. content, communication, task)
|
|
#[arg(long)]
|
|
kind: Option<String>,
|
|
|
|
/// Maks antall resultater (default: 20)
|
|
#[arg(long, default_value_t = 20)]
|
|
limit: i64,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct SearchResult {
|
|
id: Uuid,
|
|
node_kind: String,
|
|
title: Option<String>,
|
|
visibility: String,
|
|
created_at: chrono::DateTime<chrono::Utc>,
|
|
rank: f32,
|
|
headline: Option<String>,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
synops_common::logging::init("synops_search");
|
|
|
|
let cli = Cli::parse();
|
|
|
|
if let Err(e) = run(cli).await {
|
|
eprintln!("Feil: {e}");
|
|
process::exit(1);
|
|
}
|
|
}
|
|
|
|
async fn run(cli: Cli) -> Result<(), String> {
|
|
let db = synops_common::db::connect().await?;
|
|
|
|
let query = &cli.query;
|
|
|
|
// Bygg SQL dynamisk basert på om --kind er satt
|
|
let results = if let Some(ref kind) = cli.kind {
|
|
sqlx::query_as::<_, SearchResult>(
|
|
"SELECT \
|
|
n.id, \
|
|
n.node_kind::text, \
|
|
n.title, \
|
|
n.visibility::text, \
|
|
n.created_at, \
|
|
ts_rank(n.search_vector, websearch_to_tsquery('norwegian', $1)) AS rank, \
|
|
ts_headline('norwegian', COALESCE(n.content, ''), websearch_to_tsquery('norwegian', $1), \
|
|
'MaxWords=35, MinWords=15, MaxFragments=1, StartSel=«, StopSel=»') AS headline \
|
|
FROM nodes n \
|
|
WHERE n.search_vector @@ websearch_to_tsquery('norwegian', $1) \
|
|
AND n.node_kind = $2 \
|
|
ORDER BY rank DESC \
|
|
LIMIT $3",
|
|
)
|
|
.bind(query)
|
|
.bind(kind)
|
|
.bind(cli.limit)
|
|
.fetch_all(&db)
|
|
.await
|
|
.map_err(|e| format!("Søkefeil: {e}"))?
|
|
} else {
|
|
sqlx::query_as::<_, SearchResult>(
|
|
"SELECT \
|
|
n.id, \
|
|
n.node_kind::text, \
|
|
n.title, \
|
|
n.visibility::text, \
|
|
n.created_at, \
|
|
ts_rank(n.search_vector, websearch_to_tsquery('norwegian', $1)) AS rank, \
|
|
ts_headline('norwegian', COALESCE(n.content, ''), websearch_to_tsquery('norwegian', $1), \
|
|
'MaxWords=35, MinWords=15, MaxFragments=1, StartSel=«, StopSel=»') AS headline \
|
|
FROM nodes n \
|
|
WHERE n.search_vector @@ websearch_to_tsquery('norwegian', $1) \
|
|
ORDER BY rank DESC \
|
|
LIMIT $2",
|
|
)
|
|
.bind(query)
|
|
.bind(cli.limit)
|
|
.fetch_all(&db)
|
|
.await
|
|
.map_err(|e| format!("Søkefeil: {e}"))?
|
|
};
|
|
|
|
tracing::info!(
|
|
query = %query,
|
|
kind = cli.kind.as_deref().unwrap_or("alle"),
|
|
results = results.len(),
|
|
"Søk utført"
|
|
);
|
|
|
|
if results.is_empty() {
|
|
println!("Ingen treff for «{query}».");
|
|
return Ok(());
|
|
}
|
|
|
|
// Bygg markdown-output
|
|
println!("# Søkeresultater for «{query}»\n");
|
|
println!("{} treff:\n", results.len());
|
|
|
|
for (i, r) in results.iter().enumerate() {
|
|
let title = r.title.as_deref().unwrap_or("Uten tittel");
|
|
let date = r.created_at.format("%Y-%m-%d %H:%M");
|
|
|
|
println!(
|
|
"{}. **{}** (`{}`)\n ID: `{}` | {} | {}\n Relevans: {:.3}",
|
|
i + 1,
|
|
title,
|
|
r.node_kind,
|
|
r.id,
|
|
r.visibility,
|
|
date,
|
|
r.rank,
|
|
);
|
|
|
|
if let Some(ref headline) = r.headline {
|
|
let hl = headline.trim();
|
|
if !hl.is_empty() {
|
|
println!(" > {hl}");
|
|
}
|
|
}
|
|
println!();
|
|
}
|
|
|
|
Ok(())
|
|
}
|