use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::domain::ValidUrl;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadedAsset {
pub url: String,
pub local_path: String,
pub asset_type: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScrapedContent {
pub title: String,
pub content: String,
pub url: ValidUrl,
pub excerpt: Option<String>,
pub author: Option<String>,
pub date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DownloadedAsset>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
pub enum ExportFormat {
Markdown,
Jsonl,
Text,
Json,
Auto,
Zvec,
}
impl ExportFormat {
#[must_use]
pub fn extension(&self) -> &'static str {
match self {
Self::Markdown => "md",
Self::Jsonl => "jsonl",
Self::Text => "txt",
Self::Json => "json",
Self::Auto => "auto",
Self::Zvec => "zvec",
}
}
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Self::Markdown => "Markdown",
Self::Jsonl => "JSONL",
Self::Text => "Text",
Self::Json => "Json",
Self::Auto => "Auto",
Self::Zvec => "Zvec",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentChunk {
pub id: Uuid,
pub url: String,
pub title: String,
pub content: String,
pub metadata: HashMap<String, String>,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embeddings: Option<Vec<f32>>,
}
impl DocumentChunk {
#[must_use]
pub fn from_scraped_content(scraped: &ScrapedContent) -> Self {
let mut metadata = HashMap::new();
if let Some(ref excerpt) = scraped.excerpt {
metadata.insert("excerpt".to_string(), excerpt.clone());
}
if let Some(ref author) = scraped.author {
metadata.insert("author".to_string(), author.clone());
}
if let Some(ref date) = scraped.date {
metadata.insert("date".to_string(), date.clone());
}
if let Ok(url) = url::Url::parse(&scraped.url.to_string()) {
if let Some(host) = url.host_str() {
metadata.insert("domain".to_string(), host.to_string());
}
}
Self {
id: Uuid::new_v4(),
url: scraped.url.to_string(),
title: scraped.title.clone(),
content: scraped.content.clone(),
metadata,
timestamp: Utc::now(),
embeddings: None,
}
}
#[must_use]
pub fn with_id(scraped: &ScrapedContent, id: Uuid) -> Self {
let mut chunk = Self::from_scraped_content(scraped);
chunk.id = id;
chunk
}
#[must_use]
pub fn with_embeddings(mut self, embeddings: Vec<f32>) -> Self {
self.embeddings = Some(embeddings);
self
}
#[must_use]
pub fn text_length(&self) -> usize {
self.content.len()
}
#[must_use]
pub fn has_embeddings(&self) -> bool {
self.embeddings.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportState {
pub domain: String,
pub processed_urls: Vec<String>,
pub last_export: Option<DateTime<Utc>>,
pub total_exported: u64,
}
impl ExportState {
#[must_use]
pub fn new(domain: impl Into<String>) -> Self {
Self {
domain: domain.into(),
processed_urls: Vec::new(),
last_export: None,
total_exported: 0,
}
}
pub fn mark_processed(&mut self, url: &str) {
if !self.processed_urls.contains(&url.to_string()) {
self.processed_urls.push(url.to_string());
self.total_exported += 1;
}
}
#[must_use]
pub fn is_processed(&self, url: &str) -> bool {
self.processed_urls.contains(&url.to_string())
}
pub fn update_timestamp(&mut self) {
self.last_export = Some(Utc::now());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_downloaded_asset_creation() {
let asset = DownloadedAsset {
url: "https://example.com/image.png".to_string(),
local_path: "/tmp/image.png".to_string(),
asset_type: "image".to_string(),
size: 1024,
};
assert_eq!(asset.url, "https://example.com/image.png");
assert_eq!(asset.size, 1024);
}
#[test]
fn test_scraped_content_with_minimal_fields() {
let content = ScrapedContent {
title: "Test Article".to_string(),
content: "Test content".to_string(),
url: ValidUrl::parse("https://example.com").unwrap(),
excerpt: None,
author: None,
date: None,
html: None,
assets: Vec::new(),
};
assert_eq!(content.title, "Test Article");
assert!(content.excerpt.is_none());
assert!(content.assets.is_empty());
}
#[test]
fn test_scraped_content_with_all_fields() {
let content = ScrapedContent {
title: "Complete Article".to_string(),
content: "Full content here".to_string(),
url: ValidUrl::parse("https://example.com/article").unwrap(),
excerpt: Some("A short excerpt".to_string()),
author: Some("John Doe".to_string()),
date: Some("2024-01-15".to_string()),
html: Some("<html>test</html>".to_string()),
assets: vec![DownloadedAsset {
url: "https://example.com/img.png".to_string(),
local_path: "/tmp/img.png".to_string(),
asset_type: "image".to_string(),
size: 2048,
}],
};
assert_eq!(content.author, Some("John Doe".to_string()));
assert_eq!(content.assets.len(), 1);
assert_eq!(content.assets[0].size, 2048);
}
}