use std::io::Write;
use std::{
fs::{self, OpenOptions},
path::{Path, PathBuf},
};
use chrono::{Duration, Utc};
use color_eyre::eyre::eyre;
use slug::slugify;
use tracing::{error, info};
use crate::core::feed::feedentry::FeedEntry;
use crate::core::feed::feedparser;
use crate::core::library::feedcategory::FeedCategory;
use crate::{
core::defs::{self, DATA_CATEGORIES_DIR, DATA_FEED, DATA_READ_LATER},
core::library::feeditem::FeedItem,
};
use serde::{Deserialize, Serialize};
#[cfg(test)]
use tempfile::TempDir;
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct ReadLaterData {
pub read_later: Vec<String>,
#[serde(skip_serializing, skip_deserializing)]
pub loaded: bool,
}
pub struct LibraryData {
pub path: PathBuf,
pub read_later: ReadLaterData,
}
impl LibraryData {
pub fn new(datapath: &Path) -> LibraryData {
load_or_create(datapath);
LibraryData {
path: PathBuf::from(datapath),
read_later: ReadLaterData::default(),
}
}
#[cfg(test)]
pub fn new_for_test() -> (LibraryData, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let path = temp_dir.path().to_path_buf();
load_or_create(&path);
(
LibraryData {
path,
read_later: ReadLaterData::default(),
},
temp_dir,
)
}
pub fn feed_exists(&self, slug: &str, category: &str) -> bool {
let feeddata = self
.path
.join(DATA_CATEGORIES_DIR)
.join(category)
.join(slug)
.join(DATA_FEED);
feeddata.exists()
}
pub fn delete_feed(&self, slug: &str, category: &str) -> color_eyre::Result<()> {
let feed_dir = self
.path
.join(DATA_CATEGORIES_DIR)
.join(category)
.join(slug);
if feed_dir.exists() {
fs::remove_dir_all(&feed_dir).map_err(|e| {
eyre!(
"Failed to delete feed directory {}: {}",
feed_dir.display(),
e
)
})
} else {
Ok(()) }
}
pub fn feed_create(&self, feed: &FeedItem) -> color_eyre::Result<()> {
let feedir = self
.path
.join(DATA_CATEGORIES_DIR)
.join(&feed.category)
.join(&feed.slug);
fs::create_dir_all(&feedir)?;
let feeddata = feedir.join(DATA_FEED);
let toml_str =
toml::to_string(feed).map_err(|e| eyre!("Failed to serialize feed: {}", e))?;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&feeddata)
.map_err(|e| eyre!("Couldn't open file {}: {}", feeddata.display(), e))?;
file.write_all(toml_str.as_bytes())
.map_err(|e| eyre!("Failed to write file {}: {}", feeddata.display(), e))
}
pub fn generate_categories_tree(&self) -> color_eyre::Result<Vec<FeedCategory>> {
let mut categories: Vec<FeedCategory> = Vec::new();
let catpath = self.path.join(DATA_CATEGORIES_DIR);
for entry in fs::read_dir(catpath)? {
let path = entry?.path();
if path.is_dir()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
let cat = FeedCategory {
title: String::from(name),
feeds: self.load_feeds_from_category(name, path.as_path())?,
};
categories.push(cat);
}
}
Ok(categories)
}
pub fn load_feeds_from_category(
&self,
category_name: &str,
category: &Path,
) -> color_eyre::Result<Vec<FeedItem>> {
let mut feeds = Vec::new();
for entry in fs::read_dir(category)? {
let path = entry?.path();
if path.is_dir() {
let feedpath = path.join(defs::DATA_FEED);
if let Ok(file) = std::fs::read_to_string(&feedpath) {
let mut feed: FeedItem = match toml::from_str(&file) {
Ok(f) => f,
Err(e) => {
return Err(eyre!("Error: feed file can't be parsed: {}", e));
}
};
feed.category = category_name.to_string();
feeds.push(feed);
}
}
}
Ok(feeds)
}
pub fn update_feed_entries(
&self,
category: &str,
feed: &FeedItem,
feedxml: Option<String>,
) -> color_eyre::Result<()> {
if Utc::now().signed_duration_since(feed.lastupdated) < Duration::minutes(5) {
return Ok(());
}
let mut feedentries = if let Some(txt) = feedxml {
feedparser::get_feed_entries_doc(&txt, &feed.author)
} else {
feedparser::get_feed_entries(feed)
}?;
feedentries.iter_mut().for_each(|e| {
let entrypath = self
.path
.join(defs::DATA_CATEGORIES_DIR)
.join(category)
.join(&feed.slug);
let item_slug = {
let base_path = entrypath.to_string_lossy();
let max_slug_len = 250usize.saturating_sub(base_path.len() + 1);
let slug = slugify(&e.title);
let slug_cut = &slug[..slug.len().min(max_slug_len)];
slug_cut.to_string()
};
e.filepath = entrypath.join(format!("{item_slug}.md"));
});
self.update_entries(feed, feedentries)
}
fn update_entries(&self, feed: &FeedItem, entries: Vec<FeedEntry>) -> color_eyre::Result<()> {
for entry in entries.iter().as_ref() {
if !entry.filepath.exists() {
let mut file = match OpenOptions::new()
.write(true)
.create_new(true)
.open(&entry.filepath)
{
Ok(file) => file,
Err(error) => {
error!(
"Error creating file '{}': {}",
entry.filepath.display(),
error
);
break;
}
};
let mut entryclone = (*entry).clone();
entryclone.text = String::new();
let entrytext = format!(
"---\n{}---\n{}",
toml::to_string(&entryclone).unwrap_or(String::new()),
&entry.text
);
file.write_all(&entrytext.into_bytes())?;
}
}
let mut feed = feed.clone();
feed.lastupdated = Utc::now();
self.feed_create(&feed)?;
Ok(())
}
pub fn save_feed_entry(&self, entry: &FeedEntry) -> color_eyre::Result<()> {
info!("Saving {:?}", entry.filepath);
let mut file = match OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&entry.filepath)
{
Ok(file) => file,
Err(error) => {
return Err(eyre!(
"Error creating file '{}': {}",
entry.filepath.display(),
error
));
}
};
let mut entryclone = (*entry).clone();
entryclone.text = String::new();
let entrytext = format!(
"---\n{}---\n{}",
toml::to_string(&entryclone).unwrap_or_default(),
&entry.text,
);
file.write_all(&entrytext.into_bytes())?;
Ok(())
}
pub fn load_feed_entries(
&self,
category: &FeedCategory,
item: &FeedItem,
) -> color_eyre::Result<Vec<FeedEntry>> {
let mut entries = vec![];
let feedir = self
.path
.join(DATA_CATEGORIES_DIR)
.join(&category.title)
.join(&item.slug);
for entry in fs::read_dir(&feedir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let contents = std::fs::read_to_string(&path)?;
if let Ok(entry) = self.parse_feed_entry(&contents, &path) {
entries.push(entry);
}
}
}
Ok(entries)
}
pub fn get_unread_feed(&self, category: &str, feed_slug: &str) -> color_eyre::Result<u16> {
let mut unread: u16 = 0;
let feedir = self
.path
.join(DATA_CATEGORIES_DIR)
.join(category)
.join(feed_slug);
for entry in fs::read_dir(feedir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let contents = std::fs::read_to_string(&path)?;
if let Ok(entry) = self.parse_feed_entry(&contents, &path)
&& !entry.seen
{
unread += 1;
}
}
}
Ok(unread)
}
fn parse_feed_entry(&self, contents: &str, path: &Path) -> color_eyre::Result<FeedEntry> {
let parts: Vec<&str> = contents.split("---").collect();
if parts.len() < 3 {
return Err(eyre!("Invalid feed entry format"));
}
let mut entry: FeedEntry = toml::from_str(parts[1])?;
entry.filepath = path.to_path_buf();
entry.text = parts[2..].join("---");
Ok(entry)
}
pub fn set_entry_seen(&self, entry: &FeedEntry) {
if !entry.seen {
let mut entry = entry.clone();
entry.seen = true;
if let Err(e) = self.save_feed_entry(&entry) {
error!("Couldn't set entry seen: {:?}", e);
}
}
}
pub fn toggle_entry_seen(&self, entry: &FeedEntry) {
let mut entry = entry.clone();
entry.seen = !entry.seen;
if let Err(e) = self.save_feed_entry(&entry) {
error!("Couldn't toggle entry seen: {:?}", e);
}
}
pub fn add_to_read_later(&mut self, entry: &FeedEntry) -> color_eyre::Result<()> {
self.ensure_read_later()?;
let rel_path =
self.absolute_path_to_relative_path(entry.filepath.to_str().unwrap_or_default());
if rel_path.is_empty() {
return Ok(());
}
if self.read_later.read_later.iter().any(|p| p == &rel_path) {
return Ok(());
}
self.read_later.read_later.push(rel_path);
self.save_read_later(&self.read_later)?;
Ok(())
}
pub fn remove_from_read_later(&mut self, file_path: &str) -> color_eyre::Result<()> {
self.ensure_read_later()?;
let rel_path = self.absolute_path_to_relative_path(file_path);
self.read_later.read_later.retain(|p| p != &rel_path);
self.save_read_later(&self.read_later)?;
Ok(())
}
pub fn get_read_later_feed_entries(&mut self) -> color_eyre::Result<Vec<FeedEntry>> {
let read_later_list = self.load_read_later()?;
let mut feed_entries: Vec<FeedEntry> = Vec::new();
for rel in read_later_list.read_later {
let full_path = self.path.join(DATA_CATEGORIES_DIR).join(rel);
if let Ok(contents) = std::fs::read_to_string(&full_path)
&& let Ok(fe) = self.parse_feed_entry(&contents, &full_path)
{
feed_entries.push(fe);
}
}
Ok(feed_entries)
}
pub fn is_in_read_later(&mut self, file_path: &str) -> color_eyre::Result<bool> {
self.ensure_read_later()?;
let rel_path = self.absolute_path_to_relative_path(file_path);
Ok(self.read_later.read_later.iter().any(|p| p == &rel_path))
}
fn ensure_read_later(&mut self) -> color_eyre::Result<()> {
if !self.read_later.loaded {
self.read_later = self.load_read_later()?;
}
Ok(())
}
fn load_read_later(&mut self) -> color_eyre::Result<ReadLaterData> {
let read_later_path = self.path.join(DATA_READ_LATER);
if !read_later_path.exists() {
return Ok(ReadLaterData::default());
}
let contents = std::fs::read_to_string(&read_later_path)?;
let mut read_later: ReadLaterData = toml::from_str(&contents)
.map_err(|e| eyre!("Failed to parse read later data: {}", e))?;
let original_len = read_later.read_later.len();
read_later.read_later.retain(|rel| {
let full_path = self.path.join(DATA_CATEGORIES_DIR).join(rel);
full_path.exists()
});
read_later.loaded = true;
if read_later.read_later.len() < original_len {
let _ = self.save_read_later(&read_later);
}
Ok(read_later)
}
fn save_read_later(&self, read_later_list: &ReadLaterData) -> color_eyre::Result<()> {
let read_later_path = self.path.join(DATA_READ_LATER);
let toml_str = toml::to_string(read_later_list)
.map_err(|e| eyre!("Failed to serialize read later data: {}", e))?;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&read_later_path)
.map_err(|e| {
eyre!(
"Couldn't open read later file {}: {}",
read_later_path.display(),
e
)
})?;
file.write_all(toml_str.as_bytes()).map_err(|e| {
eyre!(
"Failed to write read later file {}: {}",
read_later_path.display(),
e
)
})
}
fn absolute_path_to_relative_path(&self, file_path: &str) -> String {
let path = Path::new(file_path);
let prefix = self.path.join(DATA_CATEGORIES_DIR);
if let Ok(rel_path) = path.strip_prefix(&prefix) {
rel_path.to_str().unwrap_or_default().to_string()
} else {
String::new()
}
}
}
pub fn load_or_create(path: &Path) {
let datapath = Path::new(path);
std::fs::create_dir_all(datapath).expect("Error: Failed to create datapath directory");
std::fs::create_dir_all(datapath.join(defs::DATA_CATEGORIES_DIR))
.expect("Error: Failed to create datapath directory");
}