use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::clock::stamp;
pub const FOLDER: &str = "bunflared-feedback";
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 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,
#[serde(default)]
pub source: Option<Source>,
x: f64,
y: f64,
w: f64,
h: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Source {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component: Option<String>,
pub file: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<u32>,
}
impl Source {
pub fn at(&self) -> String {
let mut at = self.file.clone();
if let Some(line) = self.line {
at.push_str(&format!(":{line}"));
if let Some(column) = self.column {
at.push_str(&format!(":{column}"));
}
}
if let Some(component) = &self.component {
at.push_str(&format!(" in {component}"));
}
at
}
}
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}"));
}
if let Some(source) = &self.source {
let at: String = source.at().chars().take(MAX_PINNED).collect();
line.push_str(&format!(" [{at}]"));
}
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)));
}
if let Some(source) = &self.source {
lines.push_str(&format!("- Source: `{}`\n", clean(&source.at())));
}
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 fn folder_ready(folder: &Path) -> std::io::Result<()> {
fs::create_dir_all(folder)?;
let ignore = folder.join(".gitignore");
if !ignore.exists() {
fs::write(ignore, "# Client feedback collected by bunflared.\n*\n")?;
}
Ok(())
}
pub fn save_shot(folder: &Path, image: &[u8]) -> std::io::Result<String> {
let extension = match image {
[0x89, b'P', b'N', b'G', ..] => "png",
[0xFF, 0xD8, 0xFF, ..] => "jpg",
[
b'R',
b'I',
b'F',
b'F',
_,
_,
_,
_,
b'W',
b'E',
b'B',
b'P',
..,
] => "webp",
_ => return Err(std::io::ErrorKind::InvalidData.into()),
};
folder_ready(folder)?;
let name = format!("{}.{extension}", stamp());
fs::write(folder.join(&name), image)?;
Ok(name)
}
pub struct Transcript {
folder: PathBuf,
path: Mutex<Option<PathBuf>>,
}
impl Transcript {
pub fn new(folder: PathBuf) -> Self {
Self {
folder,
path: Mutex::default(),
}
}
pub fn keep(&self, line: &str) {
let mut opened = self.path.lock().unwrap();
let path =
opened.get_or_insert_with(|| self.folder.join(format!("session_{}.md", stamp())));
let fresh = !path.exists();
if folder_ready(&self.folder).is_err() {
return;
}
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else {
return;
};
let now = chrono::Local::now();
if fresh {
let _ = writeln!(file, "# Live session, {}\n", now.format("%Y-%m-%d %H:%M"));
}
let _ = writeln!(file, "- {} {line}", now.format("%H:%M:%S"));
}
}
pub fn kept(folder: &Path, name: &str) -> bool {
name.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
&& !name.contains("..")
&& folder.join(name).is_file()
}
pub 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| kept(folder, name));
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)))
}