mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
The list query selected every entry's full body so that excerpt() could keep the first 180 characters of it. Listing a few years of diary moved megabytes over the wire to render a sidebar, on every keystroke of an incremental search. It now reads the leading 1024 characters, which is more than an excerpt can use, and infers truncation from the length of what came back. Also pairs WAL with synchronous=NORMAL and an explicit busy_timeout — the usual settings for it, and neither was being set.
300 lines
8.5 KiB
Rust
300 lines
8.5 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::Row;
|
|
|
|
use crate::{
|
|
auth::Session,
|
|
error::{AppError, AppResult},
|
|
routes::media,
|
|
state::AppState,
|
|
};
|
|
|
|
#[derive(Serialize)]
|
|
pub struct EntrySummary {
|
|
pub id: i64,
|
|
pub title: String,
|
|
pub excerpt: String,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
pub shared: bool,
|
|
pub share_token: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct Entry {
|
|
pub id: i64,
|
|
pub title: String,
|
|
pub body: String,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
pub shared: bool,
|
|
pub share_token: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ListQuery {
|
|
#[serde(default)]
|
|
pub q: Option<String>,
|
|
#[serde(default)]
|
|
pub limit: Option<i64>,
|
|
#[serde(default)]
|
|
pub offset: Option<i64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct EntryInput {
|
|
#[serde(default)]
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub body: String,
|
|
/// Optional override so an entry can be backdated to the day it is about.
|
|
#[serde(default)]
|
|
pub created_at: Option<i64>,
|
|
}
|
|
|
|
/// How much of a body the list query reads. Comfortably more than an excerpt
|
|
/// needs, and a rounding error next to entries that run to thousands of words.
|
|
const HEAD_CHARS: usize = 1024;
|
|
const EXCERPT_CHARS: usize = 180;
|
|
|
|
/// A short, plain-ish preview of the body for the entry list. `head` is only
|
|
/// the leading `HEAD_CHARS` of the entry, so truncation is inferred from its
|
|
/// length rather than measured against the whole body.
|
|
fn excerpt(head: &str) -> String {
|
|
let flat: String = head
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```"))
|
|
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
let mut out: String = flat.chars().take(EXCERPT_CHARS).collect();
|
|
if flat.chars().count() > EXCERPT_CHARS || head.chars().count() >= HEAD_CHARS {
|
|
out.push('…');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Turn free text into an FTS5 prefix query, discarding anything that could be
|
|
/// read as FTS syntax.
|
|
fn fts_query(raw: &str) -> Option<String> {
|
|
let terms: Vec<String> = raw
|
|
.split_whitespace()
|
|
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
|
|
.flat_map(|term| {
|
|
term.split_whitespace()
|
|
.map(str::to_string)
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.map(|term| format!("\"{term}\"*"))
|
|
.collect();
|
|
(!terms.is_empty()).then(|| terms.join(" AND "))
|
|
}
|
|
|
|
pub async fn list(
|
|
_: Session,
|
|
State(state): State<AppState>,
|
|
Query(params): Query<ListQuery>,
|
|
) -> AppResult<Json<Vec<EntrySummary>>> {
|
|
let limit = params.limit.unwrap_or(200).clamp(1, 1000);
|
|
let offset = params.offset.unwrap_or(0).max(0);
|
|
|
|
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
|
|
|
let rows = match search.and_then(fts_query) {
|
|
Some(query) => {
|
|
sqlx::query(&format!(
|
|
"SELECT e.id, e.title, substr(e.body, 1, {HEAD_CHARS}) AS body,
|
|
e.created_at, e.updated_at, e.share_token
|
|
FROM entries_fts
|
|
JOIN entries e ON e.id = entries_fts.rowid
|
|
WHERE entries_fts MATCH ?1
|
|
ORDER BY rank
|
|
LIMIT ?2 OFFSET ?3"
|
|
))
|
|
.bind(query)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(&state.db)
|
|
.await?
|
|
}
|
|
None => {
|
|
sqlx::query(&format!(
|
|
"SELECT id, title, substr(body, 1, {HEAD_CHARS}) AS body,
|
|
created_at, updated_at, share_token
|
|
FROM entries
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT ?1 OFFSET ?2"
|
|
))
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(&state.db)
|
|
.await?
|
|
}
|
|
};
|
|
|
|
let entries = rows
|
|
.into_iter()
|
|
.map(|row| {
|
|
let head: String = row.get("body");
|
|
let share_token: Option<String> = row.get("share_token");
|
|
EntrySummary {
|
|
id: row.get("id"),
|
|
title: row.get("title"),
|
|
excerpt: excerpt(&head),
|
|
created_at: row.get("created_at"),
|
|
updated_at: row.get("updated_at"),
|
|
shared: share_token.is_some(),
|
|
share_token,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(entries))
|
|
}
|
|
|
|
pub async fn get(
|
|
_: Session,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
) -> AppResult<Json<Entry>> {
|
|
load(&state, id).await.map(Json)
|
|
}
|
|
|
|
pub async fn create(
|
|
_: Session,
|
|
State(state): State<AppState>,
|
|
Json(input): Json<EntryInput>,
|
|
) -> AppResult<Json<Entry>> {
|
|
let now = crate::now();
|
|
let created_at = input.created_at.unwrap_or(now);
|
|
let id: i64 = sqlx::query(
|
|
"INSERT INTO entries (title, body, created_at, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4)
|
|
RETURNING id",
|
|
)
|
|
.bind(&input.title)
|
|
.bind(&input.body)
|
|
.bind(created_at)
|
|
.bind(now)
|
|
.fetch_one(&state.db)
|
|
.await?
|
|
.get("id");
|
|
|
|
media::link_to_entry(&state.db, id, &input.body).await?;
|
|
load(&state, id).await.map(Json)
|
|
}
|
|
|
|
pub async fn update(
|
|
_: Session,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
Json(input): Json<EntryInput>,
|
|
) -> AppResult<Json<Entry>> {
|
|
let affected = sqlx::query(
|
|
"UPDATE entries
|
|
SET title = ?1, body = ?2, updated_at = ?3, created_at = COALESCE(?4, created_at)
|
|
WHERE id = ?5",
|
|
)
|
|
.bind(&input.title)
|
|
.bind(&input.body)
|
|
.bind(crate::now())
|
|
.bind(input.created_at)
|
|
.bind(id)
|
|
.execute(&state.db)
|
|
.await?
|
|
.rows_affected();
|
|
|
|
if affected == 0 {
|
|
return Err(AppError::NotFound);
|
|
}
|
|
|
|
media::link_to_entry(&state.db, id, &input.body).await?;
|
|
load(&state, id).await.map(Json)
|
|
}
|
|
|
|
pub async fn remove(
|
|
_: Session,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
// Collected before the delete, which takes the entry_media rows with it.
|
|
// Files another entry also embeds are left alone.
|
|
let orphans = media::exclusive_media(&state.db, id).await?;
|
|
|
|
let affected = sqlx::query("DELETE FROM entries WHERE id = ?1")
|
|
.bind(id)
|
|
.execute(&state.db)
|
|
.await?
|
|
.rows_affected();
|
|
|
|
if affected == 0 {
|
|
return Err(AppError::NotFound);
|
|
}
|
|
|
|
for media_id in orphans {
|
|
media::delete_media(&state, &media_id).await?;
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({ "ok": true })))
|
|
}
|
|
|
|
async fn load(state: &AppState, id: i64) -> AppResult<Entry> {
|
|
let row = sqlx::query(
|
|
"SELECT id, title, body, created_at, updated_at, share_token FROM entries WHERE id = ?1",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
let share_token: Option<String> = row.get("share_token");
|
|
Ok(Entry {
|
|
id: row.get("id"),
|
|
title: row.get("title"),
|
|
body: row.get("body"),
|
|
created_at: row.get("created_at"),
|
|
updated_at: row.get("updated_at"),
|
|
shared: share_token.is_some(),
|
|
share_token,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{excerpt, fts_query, EXCERPT_CHARS, HEAD_CHARS};
|
|
|
|
#[test]
|
|
fn strips_markdown_furniture() {
|
|
let body = "# Title\n\n\n\n> a quote\n- a point";
|
|
assert_eq!(excerpt(body), "Title a quote a point");
|
|
}
|
|
|
|
#[test]
|
|
fn marks_a_body_that_was_cut_short() {
|
|
assert!(!excerpt("short").ends_with('…'));
|
|
assert!(excerpt(&"word ".repeat(60)).ends_with('…'));
|
|
// A head at the read limit is truncated even when it flattens small.
|
|
assert!(excerpt(&"\n".repeat(HEAD_CHARS / 18)).ends_with('…'));
|
|
}
|
|
|
|
#[test]
|
|
fn excerpt_never_exceeds_its_budget() {
|
|
let out = excerpt(&"a".repeat(HEAD_CHARS));
|
|
assert_eq!(out.chars().count(), EXCERPT_CHARS + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn fts_query_discards_operators() {
|
|
// Anything that could be read as FTS syntax must not survive as syntax.
|
|
assert_eq!(fts_query("hello"), Some("\"hello\"*".into()));
|
|
assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into()));
|
|
assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into()));
|
|
assert_eq!(fts_query(" "), None);
|
|
assert_eq!(fts_query("***"), None);
|
|
}
|
|
}
|