mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(tags): revamp tags tab — tooltips, entry counts, Create/Move flows, Esc handling (#29)
1621 lines
53 KiB
Rust
1621 lines
53 KiB
Rust
use anyhow::{Context, Result, bail};
|
|
use rusqlite::OptionalExtension;
|
|
use std::{
|
|
env, fs,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use crate::database;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ArchivePaths {
|
|
pub archive_path: PathBuf,
|
|
pub store_path: PathBuf,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub struct EntrySummary {
|
|
pub entry_uid: String,
|
|
pub archived_at: String,
|
|
pub source_kind: String,
|
|
pub entity_kind: String,
|
|
pub title: Option<String>,
|
|
pub visibility: String,
|
|
pub original_url: Option<String>,
|
|
pub artifact_count: i64,
|
|
pub total_artifact_bytes: i64,
|
|
pub parent_entry_uid: Option<String>,
|
|
/// True if a `favicon` artifact exists for this entry.
|
|
pub has_favicon: bool,
|
|
/// Bytes of blobs already on disk from an earlier entry (precomputed at capture time).
|
|
pub cached_bytes: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub struct EntryDetail {
|
|
pub summary: EntrySummary,
|
|
pub structured_root_relpath: String,
|
|
pub source_metadata_json: String,
|
|
pub display_metadata_json: Option<String>,
|
|
pub artifacts: Vec<EntryArtifactSummary>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub struct EntryArtifactSummary {
|
|
pub artifact_role: String,
|
|
pub storage_area: String,
|
|
pub relpath: String,
|
|
pub byte_size: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub struct RunSummary {
|
|
pub run_uid: String,
|
|
pub started_at: String,
|
|
pub finished_at: Option<String>,
|
|
pub status: String,
|
|
pub requested_count: i64,
|
|
pub discovered_count: i64,
|
|
pub completed_count: i64,
|
|
pub failed_count: i64,
|
|
pub error_summary: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub struct CaptureJobSummary {
|
|
pub job_uid: String,
|
|
pub archive_id: String,
|
|
pub run_uid: Option<String>,
|
|
pub status: String,
|
|
pub error_text: Option<String>,
|
|
pub notes_json: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub struct Tag {
|
|
pub tag_uid: String,
|
|
pub name: String,
|
|
pub slug: String,
|
|
pub full_path: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct TagNode {
|
|
pub tag: Tag,
|
|
pub entry_count: i64,
|
|
pub subtree_count: i64,
|
|
pub children: Vec<TagNode>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub struct CollectionSummary {
|
|
pub collection_uid: String,
|
|
pub name: String,
|
|
pub slug: String,
|
|
pub default_visibility_bits: u32,
|
|
pub created_at: String,
|
|
}
|
|
|
|
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
|
|
let mut dir = start.to_path_buf();
|
|
loop {
|
|
let candidate = dir.join(".archivr");
|
|
if candidate.is_dir() {
|
|
return Ok(Some(candidate));
|
|
}
|
|
if !dir.pop() {
|
|
return Ok(None);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn find_archive_path() -> Result<Option<PathBuf>> {
|
|
let cwd = env::current_dir().context("failed to read current working directory")?;
|
|
find_archive_path_from(&cwd)
|
|
}
|
|
|
|
pub fn read_archive_paths(archive_path: &Path) -> Result<ArchivePaths> {
|
|
if !archive_path.is_dir() {
|
|
bail!("archive path does not exist: {}", archive_path.display());
|
|
}
|
|
|
|
let name = fs::read_to_string(archive_path.join("name"))
|
|
.with_context(|| format!("failed to read archive name in {}", archive_path.display()))?
|
|
.trim()
|
|
.to_string();
|
|
let store_path = fs::read_to_string(archive_path.join("store_path"))
|
|
.with_context(|| format!("failed to read store path in {}", archive_path.display()))?;
|
|
|
|
Ok(ArchivePaths {
|
|
archive_path: archive_path.to_path_buf(),
|
|
store_path: PathBuf::from(store_path.trim()),
|
|
name,
|
|
})
|
|
}
|
|
|
|
pub fn initialize_archive(
|
|
archive_parent: &Path,
|
|
store_path: &Path,
|
|
archive_name: &str,
|
|
force_with_info_removal: bool,
|
|
) -> Result<ArchivePaths> {
|
|
let archive_path = archive_parent.join(".archivr");
|
|
|
|
if archive_path.exists() {
|
|
if !archive_path.is_dir() {
|
|
bail!(
|
|
"Archive path exists and is not a directory: {}",
|
|
archive_path.display()
|
|
);
|
|
}
|
|
|
|
if force_with_info_removal {
|
|
fs::remove_dir_all(&archive_path)?;
|
|
} else if fs::read_dir(&archive_path)?.next().is_some() {
|
|
bail!(
|
|
"Archive already exists at {} and is not empty. Use --force-with-info-removal to reinitialize.",
|
|
archive_path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
if store_path.exists() && !force_with_info_removal {
|
|
bail!("Store path already exists at {}", store_path.display());
|
|
}
|
|
|
|
fs::create_dir_all(&archive_path)?;
|
|
fs::create_dir_all(store_path)?;
|
|
fs::write(archive_path.join("name"), archive_name)?;
|
|
let canonical_store_path = store_path
|
|
.canonicalize()
|
|
.with_context(|| format!("failed to canonicalize {}", store_path.display()))?;
|
|
fs::write(
|
|
archive_path.join("store_path"),
|
|
canonical_store_path
|
|
.to_str()
|
|
.context("store path is not valid UTF-8")?,
|
|
)?;
|
|
|
|
initialize_store_directories(&canonical_store_path)?;
|
|
let conn = database::open_or_initialize(&archive_path)?;
|
|
let _ = database::ensure_default_user(&conn)?;
|
|
|
|
Ok(ArchivePaths {
|
|
archive_path,
|
|
store_path: canonical_store_path,
|
|
name: archive_name.to_string(),
|
|
})
|
|
}
|
|
|
|
pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
|
|
fs::create_dir_all(store_path.join("raw"))?;
|
|
fs::create_dir_all(store_path.join("raw_tweets"))?;
|
|
fs::create_dir_all(store_path.join("structured"))?;
|
|
fs::create_dir_all(store_path.join("temp"))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn list_root_entries(
|
|
conn: &rusqlite::Connection,
|
|
caller_bits: u32,
|
|
) -> Result<Vec<EntrySummary>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT
|
|
e.entry_uid,
|
|
e.archived_at,
|
|
e.source_kind,
|
|
e.entity_kind,
|
|
e.title,
|
|
e.visibility,
|
|
si.canonical_url,
|
|
COUNT(ea.id) AS artifact_count,
|
|
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
|
|
NULL AS parent_entry_uid,
|
|
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon,
|
|
e.cached_bytes
|
|
FROM archived_entries e
|
|
JOIN source_identities si ON si.id = e.source_identity_id
|
|
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
|
|
LEFT JOIN blobs b ON b.id = ea.blob_id
|
|
WHERE e.parent_entry_id IS NULL
|
|
AND (
|
|
CAST(?1 AS INTEGER) & 12 != 0
|
|
OR EXISTS (
|
|
SELECT 1 FROM collection_entries ce
|
|
WHERE ce.entry_id = e.id
|
|
AND ce.visibility_bits & CAST(?1 AS INTEGER) != 0
|
|
)
|
|
)
|
|
GROUP BY e.id
|
|
ORDER BY e.archived_at DESC, e.id DESC",
|
|
)?;
|
|
|
|
let entries = stmt
|
|
.query_map([caller_bits as i64], |row| {
|
|
Ok(EntrySummary {
|
|
entry_uid: row.get(0)?,
|
|
archived_at: row.get(1)?,
|
|
source_kind: row.get(2)?,
|
|
entity_kind: row.get(3)?,
|
|
title: row.get(4)?,
|
|
visibility: row.get(5)?,
|
|
original_url: row.get(6)?,
|
|
artifact_count: row.get(7)?,
|
|
total_artifact_bytes: row.get(8)?,
|
|
parent_entry_uid: row.get(9)?,
|
|
has_favicon: row.get::<_, i64>(10)? != 0,
|
|
cached_bytes: row.get(11)?,
|
|
})
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
pub fn get_entry_detail(
|
|
conn: &rusqlite::Connection,
|
|
entry_uid: &str,
|
|
) -> Result<Option<EntryDetail>> {
|
|
let Some((entry_id, structured_root_relpath, source_metadata_json, display_metadata_json)) =
|
|
conn.query_row(
|
|
"SELECT id, structured_root_relpath, source_metadata_json, display_metadata_json
|
|
FROM archived_entries
|
|
WHERE entry_uid = ?1",
|
|
[entry_uid],
|
|
|row| {
|
|
Ok((
|
|
row.get::<_, i64>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, String>(2)?,
|
|
row.get::<_, Option<String>>(3)?,
|
|
))
|
|
},
|
|
)
|
|
.optional()?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let summary = list_root_entries(conn, u32::MAX)?
|
|
.into_iter()
|
|
.find(|entry| entry.entry_uid == entry_uid)
|
|
.context("entry disappeared while loading detail")?;
|
|
|
|
let mut stmt = conn.prepare(
|
|
"SELECT ea.artifact_role, ea.storage_area, ea.relpath, b.byte_size
|
|
FROM entry_artifacts ea
|
|
LEFT JOIN blobs b ON b.id = ea.blob_id
|
|
WHERE ea.entry_id = ?1
|
|
ORDER BY ea.id ASC",
|
|
)?;
|
|
let artifacts = stmt
|
|
.query_map([entry_id], |row| {
|
|
Ok(EntryArtifactSummary {
|
|
artifact_role: row.get(0)?,
|
|
storage_area: row.get(1)?,
|
|
relpath: row.get(2)?,
|
|
byte_size: row.get(3)?,
|
|
})
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
|
|
Ok(Some(EntryDetail {
|
|
summary,
|
|
structured_root_relpath,
|
|
source_metadata_json,
|
|
display_metadata_json,
|
|
artifacts,
|
|
}))
|
|
}
|
|
|
|
pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT run_uid, started_at, finished_at, status, requested_count,
|
|
discovered_count, completed_count, failed_count, error_summary
|
|
FROM archive_runs
|
|
ORDER BY started_at DESC, id DESC",
|
|
)?;
|
|
let runs = stmt
|
|
.query_map([], |row| {
|
|
Ok(RunSummary {
|
|
run_uid: row.get(0)?,
|
|
started_at: row.get(1)?,
|
|
finished_at: row.get(2)?,
|
|
status: row.get(3)?,
|
|
requested_count: row.get(4)?,
|
|
discovered_count: row.get(5)?,
|
|
completed_count: row.get(6)?,
|
|
failed_count: row.get(7)?,
|
|
error_summary: row.get(8)?,
|
|
})
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
|
|
Ok(runs)
|
|
}
|
|
|
|
pub fn get_capture_job(
|
|
conn: &rusqlite::Connection,
|
|
job_uid: &str,
|
|
) -> Result<Option<CaptureJobSummary>> {
|
|
Ok(
|
|
database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
|
|
job_uid: r.job_uid,
|
|
archive_id: r.archive_id,
|
|
run_uid: r.run_uid,
|
|
status: r.status,
|
|
error_text: r.error_text,
|
|
notes_json: r.notes_json,
|
|
created_at: r.created_at,
|
|
updated_at: r.updated_at,
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Lists all collections in the archive.
|
|
pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSummary>> {
|
|
let records = database::list_collections(conn)?;
|
|
Ok(records
|
|
.into_iter()
|
|
.map(|r| CollectionSummary {
|
|
collection_uid: r.collection_uid,
|
|
name: r.name,
|
|
slug: r.slug,
|
|
default_visibility_bits: r.default_visibility_bits,
|
|
created_at: r.created_at,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Represents an entry's membership in a collection with its visibility bits.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct EntryCollectionMembership {
|
|
pub collection_uid: String,
|
|
pub visibility_bits: u32,
|
|
}
|
|
|
|
/// Returns collection memberships for the given entry_uid.
|
|
/// Returns Ok(None) if the entry_uid does not exist.
|
|
pub fn get_entry_collections(
|
|
conn: &rusqlite::Connection,
|
|
entry_uid: &str,
|
|
) -> Result<Option<Vec<EntryCollectionMembership>>> {
|
|
let Some(entry_id) = conn
|
|
.query_row(
|
|
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
|
[entry_uid],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.optional()?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let memberships = database::get_entry_collection_memberships(conn, entry_id)?;
|
|
Ok(Some(
|
|
memberships
|
|
.into_iter()
|
|
.map(|(_, uid, bits)| EntryCollectionMembership {
|
|
collection_uid: uid,
|
|
visibility_bits: bits,
|
|
})
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
/// Returns entries belonging to a collection, filtered by caller_bits visibility.
|
|
/// Caller with admin/owner bits (4|8) sees all entries regardless of visibility_bits.
|
|
pub fn list_entries_for_collection(
|
|
conn: &rusqlite::Connection,
|
|
collection_id: i64,
|
|
caller_bits: u32,
|
|
) -> Result<Vec<EntrySummary>> {
|
|
let sql = format!(
|
|
"{} {} \
|
|
JOIN collection_entries ce ON ce.entry_id = e.id \
|
|
WHERE ce.collection_id = ?1 \
|
|
AND (CAST(?2 AS INTEGER) & 12 != 0 \
|
|
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
|
|
GROUP BY e.id \
|
|
ORDER BY e.archived_at DESC, e.id DESC",
|
|
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
|
);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let entries = stmt
|
|
.query_map([collection_id, caller_bits as i64], |row| {
|
|
Ok(EntrySummary {
|
|
entry_uid: row.get(0)?,
|
|
archived_at: row.get(1)?,
|
|
source_kind: row.get(2)?,
|
|
entity_kind: row.get(3)?,
|
|
title: row.get(4)?,
|
|
visibility: row.get(5)?,
|
|
original_url: row.get(6)?,
|
|
artifact_count: row.get(7)?,
|
|
total_artifact_bytes: row.get(8)?,
|
|
parent_entry_uid: row.get(9)?,
|
|
has_favicon: row.get::<_, i64>(10)? != 0,
|
|
cached_bytes: row.get(11)?,
|
|
})
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Resolves an artifact to its absolute on-disk path under `store_path`.
|
|
///
|
|
/// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`).
|
|
/// The returned path is canonicalized. Returns an error if the resolved path
|
|
/// escapes `store_path` (path traversal protection) or if the file does not exist.
|
|
pub fn resolve_artifact_path(
|
|
store_path: &Path,
|
|
artifact: &EntryArtifactSummary,
|
|
) -> Result<PathBuf> {
|
|
let joined = store_path.join(&artifact.relpath);
|
|
let canonical_store = store_path.canonicalize().with_context(|| {
|
|
format!(
|
|
"failed to canonicalize store path: {}",
|
|
store_path.display()
|
|
)
|
|
})?;
|
|
let canonical_artifact = joined
|
|
.canonicalize()
|
|
.with_context(|| format!("artifact path does not exist: {}", joined.display()))?;
|
|
if !canonical_artifact.starts_with(&canonical_store) {
|
|
bail!(
|
|
"artifact path escapes store: {}",
|
|
canonical_artifact.display()
|
|
);
|
|
}
|
|
Ok(canonical_artifact)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SearchEntriesQuery {
|
|
/// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility
|
|
pub q: Option<String>,
|
|
/// Exact match on e.source_kind
|
|
pub source_kind: Option<String>,
|
|
/// Exact match on e.entity_kind
|
|
pub entity_kind: Option<String>,
|
|
/// LIKE-matched against si.canonical_url
|
|
pub url: Option<String>,
|
|
/// LIKE-matched against e.title
|
|
pub title: Option<String>,
|
|
/// e.archived_at >= after (inclusive, ISO 8601)
|
|
pub after: Option<String>,
|
|
/// e.archived_at < before (exclusive, ISO 8601)
|
|
pub before: Option<String>,
|
|
/// Tag full_path filter; includes all entries (root + child) matching the tag subtree
|
|
pub tag: Option<String>,
|
|
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters.
|
|
/// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
|
|
pub caller_bits: u32,
|
|
}
|
|
|
|
impl Default for SearchEntriesQuery {
|
|
fn default() -> Self {
|
|
Self {
|
|
q: None,
|
|
source_kind: None,
|
|
entity_kind: None,
|
|
url: None,
|
|
title: None,
|
|
after: None,
|
|
before: None,
|
|
tag: None,
|
|
caller_bits: u32::MAX,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parses a raw search string into a [`SearchEntriesQuery`].
|
|
///
|
|
/// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`, `tag:`.
|
|
/// Tokens with an unrecognized `prefix:` return `Err(prefix)`.
|
|
/// Remaining non-prefix tokens are joined as the free-text `q`.
|
|
/// Quoted values (`title:"resume templates"`) are supported for single-word values
|
|
/// after the colon; leading/trailing double quotes are stripped.
|
|
pub fn parse_search_query(raw: &str) -> Result<SearchEntriesQuery, String> {
|
|
let mut query = SearchEntriesQuery::default();
|
|
let mut free_text_tokens: Vec<&str> = Vec::new();
|
|
|
|
for token in raw.split_whitespace() {
|
|
if let Some(colon_pos) = token.find(':') {
|
|
let prefix = &token[..colon_pos];
|
|
let value_raw = &token[colon_pos + 1..];
|
|
// Strip surrounding double quotes if present
|
|
let value = value_raw.trim_matches('"').to_string();
|
|
|
|
match prefix {
|
|
"source" => query.source_kind = Some(value),
|
|
"type" => query.entity_kind = Some(value),
|
|
"url" => query.url = Some(value),
|
|
"title" => query.title = Some(value),
|
|
"after" => query.after = Some(value),
|
|
"before" => query.before = Some(value),
|
|
"tag" => query.tag = Some(value),
|
|
other => return Err(other.to_string()),
|
|
}
|
|
} else {
|
|
free_text_tokens.push(token);
|
|
}
|
|
}
|
|
|
|
let q = free_text_tokens.join(" ");
|
|
query.q = if q.is_empty() { None } else { Some(q) };
|
|
|
|
Ok(query)
|
|
}
|
|
|
|
const ENTRY_SELECT_COLS: &str = "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \
|
|
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \
|
|
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \
|
|
parent.entry_uid AS parent_entry_uid, \
|
|
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \
|
|
e.cached_bytes";
|
|
|
|
const ENTRY_FROM_JOINS: &str = "FROM archived_entries e \
|
|
JOIN source_identities si ON si.id = e.source_identity_id \
|
|
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \
|
|
LEFT JOIN blobs b ON b.id = ea.blob_id \
|
|
LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id";
|
|
|
|
/// Searches archived entries matching all non-`None` fields in `query`.
|
|
///
|
|
/// Without a tag filter, returns root entries only (same scope as [`list_root_entries`]).
|
|
/// With a tag filter, returns ALL entries (root and child) assigned to that tag subtree.
|
|
pub fn search_entries(
|
|
conn: &rusqlite::Connection,
|
|
query: &SearchEntriesQuery,
|
|
) -> Result<Vec<EntrySummary>> {
|
|
let mut params: Vec<String> = Vec::new();
|
|
let mut sql;
|
|
|
|
if let Some(tag_path) = &query.tag {
|
|
params.push(tag_path.clone());
|
|
sql = format!(
|
|
"WITH RECURSIVE descendants(id) AS (\
|
|
SELECT id FROM tags WHERE full_path = ?1 \
|
|
UNION ALL \
|
|
SELECT child.id FROM tags child \
|
|
JOIN descendants d ON child.parent_tag_id = d.id\
|
|
) {} {} \
|
|
JOIN entry_tag_assignments eta ON eta.entry_id = e.id \
|
|
JOIN descendants d ON eta.tag_id = d.id \
|
|
WHERE 1=1",
|
|
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
|
);
|
|
} else {
|
|
sql = format!(
|
|
"{} {} WHERE e.parent_entry_id IS NULL",
|
|
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
|
);
|
|
}
|
|
|
|
if let Some(q) = &query.q {
|
|
let term = format!("%{}%", q.to_lowercase());
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(
|
|
" AND (LOWER(e.title) LIKE ?{n} OR LOWER(si.canonical_url) LIKE ?{n} \
|
|
OR LOWER(e.entry_uid) LIKE ?{n} OR LOWER(e.source_kind) LIKE ?{n} \
|
|
OR LOWER(e.entity_kind) LIKE ?{n} OR LOWER(e.visibility) LIKE ?{n})"
|
|
));
|
|
params.push(term);
|
|
}
|
|
if let Some(sk) = &query.source_kind {
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(" AND e.source_kind = ?{n}"));
|
|
params.push(sk.clone());
|
|
}
|
|
if let Some(ek) = &query.entity_kind {
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(" AND e.entity_kind = ?{n}"));
|
|
params.push(ek.clone());
|
|
}
|
|
if let Some(u) = &query.url {
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(" AND LOWER(si.canonical_url) LIKE ?{n}"));
|
|
params.push(format!("%{}%", u.to_lowercase()));
|
|
}
|
|
if let Some(t) = &query.title {
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(" AND LOWER(e.title) LIKE ?{n}"));
|
|
params.push(format!("%{}%", t.to_lowercase()));
|
|
}
|
|
if let Some(a) = &query.after {
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(" AND e.archived_at >= ?{n}"));
|
|
params.push(a.clone());
|
|
}
|
|
if let Some(b) = &query.before {
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(" AND e.archived_at < ?{n}"));
|
|
params.push(b.clone());
|
|
}
|
|
|
|
// Visibility filter
|
|
let n = params.len() + 1;
|
|
sql.push_str(&format!(
|
|
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
|
|
OR EXISTS (SELECT 1 FROM collection_entries ce \
|
|
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
|
|
));
|
|
params.push(query.caller_bits.to_string());
|
|
|
|
sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let entries = stmt
|
|
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
|
Ok(EntrySummary {
|
|
entry_uid: row.get(0)?,
|
|
archived_at: row.get(1)?,
|
|
source_kind: row.get(2)?,
|
|
entity_kind: row.get(3)?,
|
|
title: row.get(4)?,
|
|
visibility: row.get(5)?,
|
|
original_url: row.get(6)?,
|
|
artifact_count: row.get(7)?,
|
|
total_artifact_bytes: row.get(8)?,
|
|
parent_entry_uid: row.get(9)?,
|
|
has_favicon: row.get::<_, i64>(10)? != 0,
|
|
cached_bytes: row.get(11)?,
|
|
})
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
Ok(entries)
|
|
}
|
|
|
|
fn tag_by_id(conn: &rusqlite::Connection, id: i64) -> Result<Tag> {
|
|
conn.query_row(
|
|
"SELECT tag_uid, name, slug, full_path FROM tags WHERE id = ?1",
|
|
[id],
|
|
|row| {
|
|
Ok(Tag {
|
|
tag_uid: row.get(0)?,
|
|
name: row.get(1)?,
|
|
slug: row.get(2)?,
|
|
full_path: row.get(3)?,
|
|
})
|
|
},
|
|
)
|
|
.context("tag not found by id")
|
|
}
|
|
|
|
/// Creates all tag path segments (idempotent) and returns the leaf `Tag`.
|
|
pub fn create_tag(conn: &rusqlite::Connection, full_path: &str) -> Result<Tag> {
|
|
let id = database::create_tag_path(conn, full_path)?;
|
|
tag_by_id(conn, id)
|
|
}
|
|
|
|
/// Returns the full tag tree with root nodes at the top level and children nested.
|
|
/// Each node includes a direct entry count and a subtree count (unique entries
|
|
/// assigned to the tag itself or any descendant).
|
|
pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
|
|
use std::collections::HashMap;
|
|
|
|
let records = database::list_all_tags(conn)?;
|
|
|
|
// Fetch direct entry counts for all tags in a single query.
|
|
let mut counts: HashMap<String, i64> = HashMap::new();
|
|
{
|
|
let mut stmt = conn.prepare(
|
|
"SELECT t.tag_uid, COUNT(eta.entry_id) \
|
|
FROM tags t \
|
|
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = t.id \
|
|
GROUP BY t.id",
|
|
)?;
|
|
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
|
|
let (uid, cnt) = row?;
|
|
counts.insert(uid, cnt);
|
|
}
|
|
}
|
|
|
|
// Fetch subtree entry counts: for each tag, count distinct entries assigned
|
|
// to it or any descendant. COUNT(DISTINCT) ensures an entry tagged at both
|
|
// a parent and a child is counted only once.
|
|
let mut subtree_counts: HashMap<String, i64> = HashMap::new();
|
|
{
|
|
let mut stmt = conn.prepare(
|
|
"WITH RECURSIVE descendants(ancestor_id, descendant_id) AS ( \
|
|
SELECT id, id FROM tags \
|
|
UNION ALL \
|
|
SELECT d.ancestor_id, t.id \
|
|
FROM tags t JOIN descendants d ON t.parent_tag_id = d.descendant_id \
|
|
) \
|
|
SELECT t.tag_uid, COUNT(DISTINCT eta.entry_id) \
|
|
FROM tags t \
|
|
JOIN descendants d ON d.ancestor_id = t.id \
|
|
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = d.descendant_id \
|
|
GROUP BY t.id",
|
|
)?;
|
|
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
|
|
let (uid, cnt) = row?;
|
|
subtree_counts.insert(uid, cnt);
|
|
}
|
|
}
|
|
|
|
let mut by_parent: HashMap<Option<i64>, Vec<database::TagRecord>> = HashMap::new();
|
|
for record in records {
|
|
by_parent
|
|
.entry(record.parent_tag_id)
|
|
.or_default()
|
|
.push(record);
|
|
}
|
|
|
|
fn build_nodes(
|
|
parent_id: Option<i64>,
|
|
by_parent: &HashMap<Option<i64>, Vec<database::TagRecord>>,
|
|
counts: &HashMap<String, i64>,
|
|
subtree_counts: &HashMap<String, i64>,
|
|
) -> Vec<TagNode> {
|
|
let Some(children) = by_parent.get(&parent_id) else {
|
|
return Vec::new();
|
|
};
|
|
children
|
|
.iter()
|
|
.map(|r| TagNode {
|
|
tag: Tag {
|
|
tag_uid: r.tag_uid.clone(),
|
|
name: r.name.clone(),
|
|
slug: r.slug.clone(),
|
|
full_path: r.full_path.clone(),
|
|
},
|
|
entry_count: counts.get(&r.tag_uid).copied().unwrap_or(0),
|
|
subtree_count: subtree_counts.get(&r.tag_uid).copied().unwrap_or(0),
|
|
children: build_nodes(Some(r.id), by_parent, counts, subtree_counts),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
Ok(build_nodes(None, &by_parent, &counts, &subtree_counts))
|
|
}
|
|
|
|
/// Returns the tags assigned to an entry.
|
|
///
|
|
/// Returns `Ok(None)` if the entry_uid does not exist (caller maps to 404).
|
|
/// Returns `Ok(Some([]))` if the entry exists but has no tags.
|
|
pub fn get_entry_tags(conn: &rusqlite::Connection, entry_uid: &str) -> Result<Option<Vec<Tag>>> {
|
|
let Some(entry_id) = conn
|
|
.query_row(
|
|
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
|
[entry_uid],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.optional()?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let records = database::list_tags_for_entry(conn, entry_id)?;
|
|
Ok(Some(
|
|
records
|
|
.into_iter()
|
|
.map(|r| Tag {
|
|
tag_uid: r.tag_uid,
|
|
name: r.name,
|
|
slug: r.slug,
|
|
full_path: r.full_path,
|
|
})
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
/// Assigns a tag (by full path, creating it if needed) to an entry.
|
|
///
|
|
/// Returns `Ok(None)` if the entry_uid does not exist.
|
|
/// Returns `Ok(Some(tag))` on success.
|
|
pub fn assign_entry_tag(
|
|
conn: &rusqlite::Connection,
|
|
entry_uid: &str,
|
|
tag_full_path: &str,
|
|
) -> Result<Option<Tag>> {
|
|
let Some(entry_id) = conn
|
|
.query_row(
|
|
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
|
[entry_uid],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.optional()?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let tag_id = database::create_tag_path(conn, tag_full_path)?;
|
|
database::assign_entry_to_tag(conn, entry_id, tag_id)?;
|
|
Ok(Some(tag_by_id(conn, tag_id)?))
|
|
}
|
|
|
|
/// Removes a tag assignment from an entry.
|
|
///
|
|
/// Returns `Ok(false)` if either the entry_uid or tag_uid is not found.
|
|
/// Returns `Ok(true)` on success (even if no row was deleted, i.e. assignment didn't exist).
|
|
pub fn remove_entry_tag(
|
|
conn: &rusqlite::Connection,
|
|
entry_uid: &str,
|
|
tag_uid: &str,
|
|
) -> Result<bool> {
|
|
let Some(entry_id) = conn
|
|
.query_row(
|
|
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
|
[entry_uid],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.optional()?
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
let Some(tag_record) = database::get_tag_by_uid(conn, tag_uid)? else {
|
|
return Ok(false);
|
|
};
|
|
database::remove_entry_tag_assignment(conn, entry_id, tag_record.id)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Returns all entries (root and child) assigned to any tag in the subtree rooted at `tag_full_path`.
|
|
pub fn entries_for_tag(
|
|
conn: &rusqlite::Connection,
|
|
tag_full_path: &str,
|
|
) -> Result<Vec<EntrySummary>> {
|
|
let mut stmt = conn.prepare(
|
|
"WITH RECURSIVE descendants(id) AS (
|
|
SELECT id FROM tags WHERE full_path = ?1
|
|
UNION ALL
|
|
SELECT child.id FROM tags child
|
|
JOIN descendants d ON child.parent_tag_id = d.id
|
|
)
|
|
SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title,
|
|
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count,
|
|
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
|
|
parent.entry_uid AS parent_entry_uid,
|
|
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon,
|
|
e.cached_bytes
|
|
FROM archived_entries e
|
|
JOIN source_identities si ON si.id = e.source_identity_id
|
|
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
|
|
LEFT JOIN blobs b ON b.id = ea.blob_id
|
|
LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id
|
|
JOIN entry_tag_assignments eta ON eta.entry_id = e.id
|
|
JOIN descendants d ON eta.tag_id = d.id
|
|
GROUP BY e.id
|
|
ORDER BY e.archived_at DESC, e.id DESC",
|
|
)?;
|
|
let entries = stmt
|
|
.query_map([tag_full_path], |row| {
|
|
Ok(EntrySummary {
|
|
entry_uid: row.get(0)?,
|
|
archived_at: row.get(1)?,
|
|
source_kind: row.get(2)?,
|
|
entity_kind: row.get(3)?,
|
|
title: row.get(4)?,
|
|
visibility: row.get(5)?,
|
|
original_url: row.get(6)?,
|
|
artifact_count: row.get(7)?,
|
|
total_artifact_bytes: row.get(8)?,
|
|
parent_entry_uid: row.get(9)?,
|
|
has_favicon: row.get::<_, i64>(10)? != 0,
|
|
cached_bytes: row.get(11)?,
|
|
})
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
Ok(entries)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn unique_path(prefix: &str) -> PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id()))
|
|
}
|
|
|
|
#[test]
|
|
fn find_archive_path_walks_up_to_dot_archivr() {
|
|
let root = unique_path("archivr-core-find");
|
|
let nested = root.join("a").join("b");
|
|
fs::create_dir_all(root.join(".archivr")).unwrap();
|
|
fs::create_dir_all(&nested).unwrap();
|
|
|
|
let found = find_archive_path_from(&nested).unwrap().unwrap();
|
|
|
|
assert_eq!(found, root.join(".archivr"));
|
|
let _ = fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn read_archive_paths_returns_name_and_store_path() {
|
|
let root = unique_path("archivr-core-open");
|
|
let archive_path = root.join(".archivr");
|
|
let store_path = root.join("store");
|
|
fs::create_dir_all(&archive_path).unwrap();
|
|
fs::create_dir_all(&store_path).unwrap();
|
|
fs::write(archive_path.join("name"), "Personal").unwrap();
|
|
fs::write(
|
|
archive_path.join("store_path"),
|
|
store_path.display().to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
let paths = read_archive_paths(&archive_path).unwrap();
|
|
|
|
assert_eq!(paths.archive_path, archive_path);
|
|
assert_eq!(paths.store_path, store_path);
|
|
assert_eq!(paths.name, "Personal");
|
|
let _ = fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn initialize_archive_creates_database_store_and_metadata() {
|
|
let root = unique_path("archivr-core-init");
|
|
let archive_parent = root.join("archive");
|
|
let store_path = root.join("store");
|
|
|
|
let paths = initialize_archive(&archive_parent, &store_path, "Personal", false).unwrap();
|
|
|
|
assert_eq!(paths.archive_path, archive_parent.join(".archivr"));
|
|
assert!(
|
|
paths
|
|
.archive_path
|
|
.join(database::DATABASE_FILE_NAME)
|
|
.is_file()
|
|
);
|
|
assert!(paths.store_path.join("raw").is_dir());
|
|
assert!(paths.store_path.join("raw_tweets").is_dir());
|
|
assert!(paths.store_path.join("structured").is_dir());
|
|
assert!(paths.store_path.join("temp").is_dir());
|
|
let _ = fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn list_root_entries_returns_entry_details_and_runs() {
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
database::initialize_schema(&conn).unwrap();
|
|
let user_id = database::ensure_default_user(&conn).unwrap();
|
|
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
|
|
let item = database::create_archive_run_item(
|
|
&conn,
|
|
run.id,
|
|
None,
|
|
0,
|
|
"https://example.com/saved",
|
|
Some("https://example.com/saved"),
|
|
"web",
|
|
"page",
|
|
)
|
|
.unwrap();
|
|
let source_identity_id = database::upsert_source_identity(
|
|
&conn,
|
|
"web",
|
|
"page",
|
|
Some("saved-article"),
|
|
Some("https://example.com/saved"),
|
|
"https://example.com/saved",
|
|
)
|
|
.unwrap();
|
|
let entry = database::create_archived_entry(
|
|
&conn,
|
|
&database::NewEntry {
|
|
source_identity_id,
|
|
archive_run_id: run.id,
|
|
parent_entry_id: None,
|
|
root_entry_id: None,
|
|
created_by_user_id: user_id,
|
|
owned_by_user_id: user_id,
|
|
source_kind: "web".to_string(),
|
|
entity_kind: "page".to_string(),
|
|
title: Some("Saved Article".to_string()),
|
|
visibility: "private".to_string(),
|
|
representation_kind: "html".to_string(),
|
|
source_metadata_json: r#"{"source":"test"}"#.to_string(),
|
|
display_metadata_json: Some(r#"{"reading_time":"4m"}"#.to_string()),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let blob_id = database::upsert_blob(
|
|
&conn,
|
|
&database::BlobRecord {
|
|
sha256: "abc123".to_string(),
|
|
byte_size: 123,
|
|
mime_type: Some("text/html".to_string()),
|
|
extension: Some("html".to_string()),
|
|
raw_relpath: "raw/a/b/abc123.html".to_string(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
database::add_entry_artifact(
|
|
&conn,
|
|
&database::NewArtifact {
|
|
entry_id: entry.id,
|
|
artifact_role: "primary_media".to_string(),
|
|
storage_area: "raw".to_string(),
|
|
relpath: "raw/a/b/abc123.html".to_string(),
|
|
blob_id: Some(blob_id),
|
|
logical_path: None,
|
|
metadata_json: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
database::complete_archive_run_item(&conn, item.id, entry.id).unwrap();
|
|
database::finish_archive_run(&conn, run.id).unwrap();
|
|
|
|
let entries = list_root_entries(&conn, u32::MAX).unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].title.as_deref(), Some("Saved Article"));
|
|
assert_eq!(entries[0].artifact_count, 1);
|
|
|
|
let detail = get_entry_detail(&conn, &entries[0].entry_uid)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(detail.artifacts.len(), 1);
|
|
assert_eq!(detail.artifacts[0].artifact_role, "primary_media");
|
|
|
|
let runs = list_runs(&conn).unwrap();
|
|
assert_eq!(runs.len(), 1);
|
|
assert_eq!(runs[0].status, "completed");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_artifact_path_returns_absolute_path_within_store() {
|
|
let root = unique_path("archivr-resolve-artifact");
|
|
let store_path = root.join("store");
|
|
fs::create_dir_all(store_path.join("raw/a/b")).unwrap();
|
|
let artifact_file = store_path.join("raw/a/b/abc.pdf");
|
|
fs::write(&artifact_file, b"data").unwrap();
|
|
|
|
let artifact = EntryArtifactSummary {
|
|
artifact_role: "primary".to_string(),
|
|
storage_area: "raw".to_string(),
|
|
relpath: "raw/a/b/abc.pdf".to_string(),
|
|
byte_size: Some(4),
|
|
};
|
|
let resolved = resolve_artifact_path(&store_path, &artifact).unwrap();
|
|
assert_eq!(resolved, artifact_file.canonicalize().unwrap());
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_artifact_path_rejects_traversal() {
|
|
let root = unique_path("archivr-resolve-traversal");
|
|
let store_path = root.join("store");
|
|
fs::create_dir_all(&store_path).unwrap();
|
|
let artifact = EntryArtifactSummary {
|
|
artifact_role: "primary".to_string(),
|
|
storage_area: "raw".to_string(),
|
|
relpath: "../escaped.txt".to_string(),
|
|
byte_size: None,
|
|
};
|
|
assert!(resolve_artifact_path(&store_path, &artifact).is_err());
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
// ---- parse_search_query tests ----
|
|
|
|
#[test]
|
|
fn parse_empty_query_returns_default() {
|
|
let q = parse_search_query("").unwrap();
|
|
assert!(q.q.is_none());
|
|
assert!(q.source_kind.is_none());
|
|
assert!(q.entity_kind.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_plain_text_sets_q() {
|
|
let q = parse_search_query("polymarket").unwrap();
|
|
assert_eq!(q.q.as_deref(), Some("polymarket"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_prefix_source_sets_source_kind() {
|
|
let q = parse_search_query("source:x").unwrap();
|
|
assert_eq!(q.source_kind.as_deref(), Some("x"));
|
|
assert!(q.q.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_prefix_type_sets_entity_kind() {
|
|
let q = parse_search_query("type:tweet").unwrap();
|
|
assert_eq!(q.entity_kind.as_deref(), Some("tweet"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mixed_plain_and_prefix() {
|
|
let q = parse_search_query("polymarket type:tweet").unwrap();
|
|
assert_eq!(q.q.as_deref(), Some("polymarket"));
|
|
assert_eq!(q.entity_kind.as_deref(), Some("tweet"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_unknown_prefix_returns_err() {
|
|
let result = parse_search_query("foo:bar");
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "foo");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_after_before_dates() {
|
|
let q = parse_search_query("after:2026-01-01 before:2026-04-01").unwrap();
|
|
assert_eq!(q.after.as_deref(), Some("2026-01-01"));
|
|
assert_eq!(q.before.as_deref(), Some("2026-04-01"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_search_query_tag_prefix() {
|
|
let q = parse_search_query("tag:/science/cs").unwrap();
|
|
assert_eq!(q.tag.as_deref(), Some("/science/cs"));
|
|
assert_eq!(q.q, None);
|
|
}
|
|
|
|
// ---- search_entries tests ----
|
|
|
|
fn make_test_db_with_entries() -> rusqlite::Connection {
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
database::initialize_schema(&conn).unwrap();
|
|
let user_id = database::ensure_default_user(&conn).unwrap();
|
|
let run = database::create_archive_run(&conn, user_id, 2).unwrap();
|
|
|
|
// Entry 1: tweet by source x
|
|
let si1 = database::upsert_source_identity(
|
|
&conn,
|
|
"x",
|
|
"tweet",
|
|
Some("t-1"),
|
|
Some("https://x.com/user/status/1"),
|
|
"https://x.com/user/status/1",
|
|
)
|
|
.unwrap();
|
|
database::create_archived_entry(
|
|
&conn,
|
|
&database::NewEntry {
|
|
source_identity_id: si1,
|
|
archive_run_id: run.id,
|
|
parent_entry_id: None,
|
|
root_entry_id: None,
|
|
created_by_user_id: user_id,
|
|
owned_by_user_id: user_id,
|
|
source_kind: "x".to_string(),
|
|
entity_kind: "tweet".to_string(),
|
|
title: Some("Polymarket tweet".to_string()),
|
|
visibility: "private".to_string(),
|
|
representation_kind: "json".to_string(),
|
|
source_metadata_json: "{}".to_string(),
|
|
display_metadata_json: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
// Entry 2: web page
|
|
let si2 = database::upsert_source_identity(
|
|
&conn,
|
|
"web",
|
|
"page",
|
|
Some("page-1"),
|
|
Some("https://medium.com/article"),
|
|
"https://medium.com/article",
|
|
)
|
|
.unwrap();
|
|
database::create_archived_entry(
|
|
&conn,
|
|
&database::NewEntry {
|
|
source_identity_id: si2,
|
|
archive_run_id: run.id,
|
|
parent_entry_id: None,
|
|
root_entry_id: None,
|
|
created_by_user_id: user_id,
|
|
owned_by_user_id: user_id,
|
|
source_kind: "web".to_string(),
|
|
entity_kind: "page".to_string(),
|
|
title: Some("Resume Templates".to_string()),
|
|
visibility: "private".to_string(),
|
|
representation_kind: "html".to_string(),
|
|
source_metadata_json: "{}".to_string(),
|
|
display_metadata_json: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
conn
|
|
}
|
|
|
|
#[test]
|
|
fn search_empty_query_returns_all_root_entries() {
|
|
let conn = make_test_db_with_entries();
|
|
let all = list_root_entries(&conn, u32::MAX).unwrap();
|
|
let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
|
|
assert_eq!(all.len(), searched.len());
|
|
}
|
|
|
|
#[test]
|
|
fn search_q_filters_on_title() {
|
|
let conn = make_test_db_with_entries();
|
|
let results = search_entries(
|
|
&conn,
|
|
&SearchEntriesQuery {
|
|
q: Some("polymarket".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].entity_kind, "tweet");
|
|
}
|
|
|
|
#[test]
|
|
fn search_entity_kind_exact_match() {
|
|
let conn = make_test_db_with_entries();
|
|
let results = search_entries(
|
|
&conn,
|
|
&SearchEntriesQuery {
|
|
entity_kind: Some("page".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].source_kind, "web");
|
|
}
|
|
|
|
#[test]
|
|
fn search_url_like_filter() {
|
|
let conn = make_test_db_with_entries();
|
|
let results = search_entries(
|
|
&conn,
|
|
&SearchEntriesQuery {
|
|
url: Some("medium.com".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].title.as_deref(), Some("Resume Templates"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_no_match_returns_empty() {
|
|
let conn = make_test_db_with_entries();
|
|
let results = search_entries(
|
|
&conn,
|
|
&SearchEntriesQuery {
|
|
q: Some("zzznonexistent".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn search_multiple_filters_compound() {
|
|
let conn = make_test_db_with_entries();
|
|
let results = search_entries(
|
|
&conn,
|
|
&SearchEntriesQuery {
|
|
source_kind: Some("x".to_string()),
|
|
entity_kind: Some("tweet".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
}
|
|
|
|
// ---- tag API tests ----
|
|
|
|
fn make_tag_test_db() -> (rusqlite::Connection, i64, i64) {
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
database::initialize_schema(&conn).unwrap();
|
|
let user_id = database::ensure_default_user(&conn).unwrap();
|
|
let run = database::create_archive_run(&conn, user_id, 2).unwrap();
|
|
(conn, user_id, run.id)
|
|
}
|
|
|
|
fn make_entry_in_db(
|
|
conn: &rusqlite::Connection,
|
|
user_id: i64,
|
|
run_id: i64,
|
|
parent_entry_id: Option<i64>,
|
|
root_entry_id: Option<i64>,
|
|
title: &str,
|
|
url: &str,
|
|
) -> database::ArchivedEntry {
|
|
let si =
|
|
database::upsert_source_identity(conn, "web", "page", None, Some(url), url).unwrap();
|
|
database::create_archived_entry(
|
|
conn,
|
|
&database::NewEntry {
|
|
source_identity_id: si,
|
|
archive_run_id: run_id,
|
|
parent_entry_id,
|
|
root_entry_id,
|
|
created_by_user_id: user_id,
|
|
owned_by_user_id: user_id,
|
|
source_kind: "web".to_string(),
|
|
entity_kind: "page".to_string(),
|
|
title: Some(title.to_string()),
|
|
visibility: "private".to_string(),
|
|
representation_kind: "html".to_string(),
|
|
source_metadata_json: "{}".to_string(),
|
|
display_metadata_json: None,
|
|
},
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn tag_tree_roots_and_children() {
|
|
let (conn, _, _) = make_tag_test_db();
|
|
create_tag(&conn, "/science/cs").unwrap();
|
|
create_tag(&conn, "/art").unwrap();
|
|
|
|
let tree = list_tag_tree(&conn).unwrap();
|
|
assert_eq!(tree.len(), 2, "expected two root nodes");
|
|
|
|
let science = tree
|
|
.iter()
|
|
.find(|n| n.tag.slug == "science")
|
|
.expect("science root missing");
|
|
assert_eq!(science.children.len(), 1, "science should have one child");
|
|
assert_eq!(science.children[0].tag.slug, "cs");
|
|
|
|
let art = tree
|
|
.iter()
|
|
.find(|n| n.tag.slug == "art")
|
|
.expect("art root missing");
|
|
assert!(art.children.is_empty(), "art should have no children");
|
|
}
|
|
|
|
#[test]
|
|
fn tag_tree_entry_counts_direct_and_subtree() {
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
// Tree: /science -> /science/cs -> /science/cs/algorithms
|
|
create_tag(&conn, "/science/cs/algorithms").unwrap();
|
|
|
|
let e1 = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"E1",
|
|
"https://example.com/e1",
|
|
);
|
|
let e2 = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"E2",
|
|
"https://example.com/e2",
|
|
);
|
|
|
|
// e1 → /science/cs/algorithms (leaf), e2 → /science (root)
|
|
assign_entry_tag(&conn, &e1.entry_uid, "/science/cs/algorithms").unwrap();
|
|
assign_entry_tag(&conn, &e2.entry_uid, "/science").unwrap();
|
|
|
|
let tree = list_tag_tree(&conn).unwrap();
|
|
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
|
|
let cs = science
|
|
.children
|
|
.iter()
|
|
.find(|n| n.tag.slug == "cs")
|
|
.unwrap();
|
|
let algo = cs
|
|
.children
|
|
.iter()
|
|
.find(|n| n.tag.slug == "algorithms")
|
|
.unwrap();
|
|
|
|
assert_eq!(science.entry_count, 1, "science direct = 1");
|
|
assert_eq!(
|
|
science.subtree_count, 2,
|
|
"science subtree = 2 (e1 via algo, e2 direct)"
|
|
);
|
|
assert_eq!(cs.entry_count, 0, "cs direct = 0");
|
|
assert_eq!(cs.subtree_count, 1, "cs subtree = 1 (e1 via algo)");
|
|
assert_eq!(algo.entry_count, 1, "algo direct = 1");
|
|
assert_eq!(algo.subtree_count, 1, "algo subtree = 1");
|
|
}
|
|
|
|
#[test]
|
|
fn tag_tree_subtree_count_deduplicates_shared_entry() {
|
|
// Regression: an entry assigned to both a parent and a child must count
|
|
// as 1 in the parent's subtree_count, not 2.
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
create_tag(&conn, "/science/cs").unwrap();
|
|
|
|
let e = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"E",
|
|
"https://example.com/ded",
|
|
);
|
|
assign_entry_tag(&conn, &e.entry_uid, "/science").unwrap();
|
|
assign_entry_tag(&conn, &e.entry_uid, "/science/cs").unwrap();
|
|
|
|
let tree = list_tag_tree(&conn).unwrap();
|
|
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
|
|
|
|
assert_eq!(
|
|
science.subtree_count, 1,
|
|
"entry assigned to both parent and child must not be double-counted"
|
|
);
|
|
assert_eq!(science.entry_count, 1, "science direct = 1");
|
|
assert_eq!(science.children[0].subtree_count, 1, "cs subtree = 1");
|
|
}
|
|
|
|
#[test]
|
|
fn assign_entry_tag_is_idempotent() {
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
let entry = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"Test",
|
|
"https://example.com/t1",
|
|
);
|
|
|
|
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
|
|
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
|
|
|
|
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
|
|
assert_eq!(
|
|
tags.len(),
|
|
1,
|
|
"idempotent assign should yield exactly one tag"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_entry_tag_clears_assignment() {
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
let entry = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"Test",
|
|
"https://example.com/t2",
|
|
);
|
|
|
|
let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science")
|
|
.unwrap()
|
|
.unwrap();
|
|
remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap();
|
|
|
|
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
|
|
assert!(tags.is_empty(), "tag should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn entries_for_tag_includes_descendants() {
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
let entry = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"Compilers Paper",
|
|
"https://example.com/c1",
|
|
);
|
|
|
|
assign_entry_tag(&conn, &entry.entry_uid, "/science/cs/compilers").unwrap();
|
|
|
|
let results = entries_for_tag(&conn, "/science").unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].entry_uid, entry.entry_uid);
|
|
}
|
|
|
|
#[test]
|
|
fn entries_for_tag_includes_child_entries() {
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
let parent = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"Playlist",
|
|
"https://example.com/pl",
|
|
);
|
|
let child = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
Some(parent.id),
|
|
Some(parent.id),
|
|
"Video 1",
|
|
"https://example.com/pl/v1",
|
|
);
|
|
|
|
assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap();
|
|
|
|
let results = entries_for_tag(&conn, "/science").unwrap();
|
|
assert_eq!(results.len(), 1, "only the tagged child should appear");
|
|
assert_eq!(results[0].entry_uid, child.entry_uid);
|
|
assert_eq!(
|
|
results[0].parent_entry_uid.as_deref(),
|
|
Some(parent.entry_uid.as_str())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_with_tag_filter_works() {
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
let entry = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"Science Article",
|
|
"https://example.com/s1",
|
|
);
|
|
|
|
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
|
|
|
|
let results = search_entries(
|
|
&conn,
|
|
&SearchEntriesQuery {
|
|
tag: Some("/science".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].entry_uid, entry.entry_uid);
|
|
|
|
let empty = search_entries(
|
|
&conn,
|
|
&SearchEntriesQuery {
|
|
tag: Some("/art".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(empty.is_empty(), "no entries under /art");
|
|
}
|
|
|
|
#[test]
|
|
fn search_without_tag_returns_roots_only() {
|
|
let (conn, user_id, run_id) = make_tag_test_db();
|
|
let parent = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
None,
|
|
None,
|
|
"Parent",
|
|
"https://example.com/par",
|
|
);
|
|
let _child = make_entry_in_db(
|
|
&conn,
|
|
user_id,
|
|
run_id,
|
|
Some(parent.id),
|
|
Some(parent.id),
|
|
"Child",
|
|
"https://example.com/par/c",
|
|
);
|
|
|
|
let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
|
|
assert_eq!(
|
|
results.len(),
|
|
1,
|
|
"plain search should return root entries only"
|
|
);
|
|
assert_eq!(results[0].entry_uid, parent.entry_uid);
|
|
}
|
|
}
|