use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use super::{folder_ready, stamp};
const MAX_PINNED: usize = 300;
#[derive(Debug, Clone)]
pub struct Feedback {
pub sid: String,
pub device: String,
pub page: String,
pub message: String,
pub file: PathBuf,
pub shot: Option<PathBuf>,
pub points: Vec<Element>,
}
impl Feedback {
pub fn text(&self) -> String {
if plain(&self.points) {
return self.message.clone();
}
let mut lines = Vec::new();
if !self.message.is_empty() {
lines.push(self.message.clone());
}
lines.extend(
self.points
.iter()
.enumerate()
.map(|(i, point)| point.line(i + 1)),
);
lines.join("\n")
}
}
#[derive(Deserialize)]
pub(super) struct Note {
pub sid: String,
pub page: String,
pub message: String,
screen: Option<String>,
shot: Option<String>,
#[serde(default)]
pub points: Vec<Element>,
}
impl Note {
pub fn written(&self) -> bool {
!self.message.trim().is_empty() || self.points.iter().any(|p| !p.comment.trim().is_empty())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Element {
pub selector: String,
pub text: String,
#[serde(default)]
pub comment: String,
x: f64,
y: f64,
w: f64,
h: f64,
}
impl Element {
pub fn line(&self, number: usize) -> String {
let mut line = format!("{number}. {}", self.selector);
let quoted: String = self.text.chars().take(40).collect();
if !quoted.is_empty() {
line.push_str(&format!(" \"{quoted}\""));
}
let comment = self
.comment
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if !comment.is_empty() {
line.push_str(&format!(": {comment}"));
}
line
}
fn markdown(&self) -> String {
let clean = |text: &str| -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace(['`', '"'], "'")
.chars()
.take(MAX_PINNED)
.collect()
};
let mut lines = format!("- Element: `{}`\n", clean(&self.selector));
if !self.text.trim().is_empty() {
lines.push_str(&format!("- Text: \"{}\"\n", clean(&self.text)));
}
lines.push_str(&format!(
"- Position: {}, {} ({}×{}) from the top left of the page\n",
self.x.round(),
self.y.round(),
self.w.round(),
self.h.round()
));
lines
}
}
fn plain(points: &[Element]) -> bool {
points.len() <= 1 && points.iter().all(|p| p.comment.trim().is_empty())
}
fn quote(text: &str) -> String {
text.trim()
.lines()
.map(|line| format!("> {line}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn save_note(
folder: &Path,
note: &Note,
device: &str,
) -> std::io::Result<(String, Option<String>)> {
folder_ready(folder)?;
let shot = note.shot.as_deref().filter(|name| {
name.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
&& !name.contains("..")
&& folder.join(name).is_file()
});
let mut text = format!(
"# Feedback, {}\n\n- Page: `{}`\n- Device: {device}\n- Screen: {}\n- Visitor: {}\n",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
note.page,
note.screen.as_deref().unwrap_or("unknown"),
note.sid,
);
if plain(¬e.points) {
let pinned = note.points.first().map(Element::markdown);
text.push_str(&format!(
"{}\n{}\n",
pinned.unwrap_or_default(),
quote(¬e.message)
));
} else {
for (index, point) in note.points.iter().enumerate() {
text.push_str(&format!("\n## Point {}\n\n{}", index + 1, point.markdown()));
if !point.comment.trim().is_empty() {
text.push_str(&format!("\n{}\n", quote(&point.comment)));
}
}
if !note.message.trim().is_empty() {
text.push_str(&format!("\n## Overall\n\n{}\n", quote(¬e.message)));
}
}
if let Some(shot) = shot {
let outlined = match note.points.len() {
0 => "",
1 => ", the element outlined",
_ => ", the elements outlined and numbered",
};
text.push_str(&format!("\n\n"));
}
let name = format!("{}.md", stamp());
fs::write(folder.join(&name), text)?;
Ok((name, shot.map(str::to_string)))
}