use super::*;
pub(crate) async fn tree(State(app): S) -> Response {
match app.store.projects() {
Ok(t) => Json(t).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct TreeQ {
pub(crate) workflows: Option<usize>,
pub(crate) docs: Option<usize>,
pub(crate) whole: Option<i64>,
}
pub(crate) async fn project_tree(
State(app): S,
Path(id): Path<i64>,
Query(q): Query<TreeQ>,
) -> Response {
let workflows = q.workflows.unwrap_or(TREE_WORKFLOWS);
let docs = q.docs.unwrap_or(TREE_DOCS);
Json(project_rows(&app, id, workflows, docs, q.whole)).into_response()
}
pub(crate) async fn workflow_tree(State(app): S, Path(id): Path<i64>) -> Response {
match app.store.workflow_tree(id) {
Ok(Some(w)) => Json(w).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct Limit {
pub(crate) limit: Option<usize>,
}
pub(crate) async fn inbox(State(app): S, Query(q): Query<Limit>) -> Response {
match app.store.inbox(q.limit.unwrap_or(50).min(500)) {
Ok(t) => Json(t).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct SearchQ {
pub(crate) q: String,
pub(crate) limit: Option<usize>,
}
pub(crate) async fn search(State(app): S, Query(q): Query<SearchQ>) -> Response {
match app.store.search(&q.q, q.limit.unwrap_or(30).min(200)) {
Ok(t) => Json(t).into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn doc_json(State(app): S, Path(id): Path<String>) -> Response {
match app.store.get(&id) {
Ok(Some(doc)) => {
let body = app.store.html(&id).unwrap_or_default();
let previous = app.store.previous(&doc).ok().flatten().map(|p| p.id);
let history = doc
.source_path
.as_deref()
.and_then(|p| app.store.history(doc.project_id, p).ok())
.filter(|h| h.len() > 1);
let preview = render::preview_kind(&doc_ext(&doc));
Json(json!({
"doc": doc,
"html": doc_html(&doc, &body, has_friends(&app)),
"previous": previous,
"history": history,
"preview": preview,
"preview_url": preview.map(|_| format!("/api/docs/{id}/blob")),
"folder": doc_folder(&app, &doc),
}))
.into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn doc_raw(State(app): S, Path(id): Path<String>) -> Response {
match app.store.source(&id) {
Ok(src) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], src).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub(crate) fn doc_ext(doc: &crate::store::Doc) -> String {
match (doc.source_path.as_deref(), doc.lang.as_deref()) {
(Some(p), _) => render::ext_of(p),
(None, Some(l)) => l.trim().to_ascii_lowercase(),
(None, None) => String::new(),
}
}
pub(crate) async fn doc_blob(State(app): S, Path(id): Path<String>, req: HeaderMap) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let ext = doc_ext(&doc);
let mime = if ext.is_empty() {
"application/octet-stream".to_string()
} else {
mime_guess::from_ext(&ext)
.first_or_octet_stream()
.to_string()
};
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(&mime) {
headers.insert(header::CONTENT_TYPE, v);
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=31536000"),
);
protect(&mut headers, &ext);
serve_file(&app.store.src_path(&id), headers, &req).await
}
#[derive(Deserialize)]
pub(crate) struct ViewQ {
pub(crate) view: Option<String>,
}
pub(crate) async fn doc_split(State(app): S, Path(id): Path<String>) -> Response {
match (app.store.get(&id), app.store.source(&id)) {
(Ok(Some(doc)), Ok(src)) if doc.kind == crate::render::Kind::Diff => {
Json(json!({ "html": render::diff_split(&src) })).into_response()
}
(Ok(Some(_)), _) => StatusCode::BAD_REQUEST.into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
pub(crate) async fn doc_outline(State(app): S, Path(id): Path<String>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
if doc.kind != crate::render::Kind::Code {
return Json(Vec::<crate::render::Outline>::new()).into_response();
}
if let Some(json) = app.store.outline(&id) {
return ([(header::CONTENT_TYPE, "application/json")], json).into_response();
}
let app2 = app.clone();
match tokio::task::spawn_blocking(move || outline_of(&app2, &id, doc.lang.as_deref())).await {
Ok(Some(json)) => ([(header::CONTENT_TYPE, "application/json")], json).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
pub(crate) fn outline_of(app: &App, id: &str, lang: Option<&str>) -> Option<String> {
let src = app.store.source(id).ok()?;
let json = serde_json::to_string(&app.renderer.outline(lang, &src)).ok()?;
let _ = app.store.set_outline(id, &json);
Some(json)
}
pub(crate) async fn history(State(app): S, Path(id): Path<String>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(path) = doc.source_path.as_deref() else {
return Json(Vec::<Doc>::new()).into_response();
};
match app.store.history(doc.project_id, path) {
Ok(h) => Json(h).into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn delete_doc(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
match app.store.delete_versions(&id) {
Ok(n) if n > 0 => {
emit(
&app,
"deleted",
json!({ "id": id, "waiting": waiting(&app) }),
);
Json(json!({ "ok": true, "versions": n })).into_response()
}
Ok(_) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn removed_list(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
const ROWS: usize = 100;
let desks = refuse_desk(&app, &headers, &q).is_none();
let mut items = match app.store.removed(ROWS, desks) {
Ok(v) => v,
Err(e) => return err(e),
};
for a in app.asides.list().into_iter().filter(|a| a.dismissed) {
items.push(crate::store::Removed {
kind: "aside",
id: a.id.to_string(),
title: a.text,
from: a.sender.unwrap_or_default(),
desk: None,
at: a.at,
versions: 1,
restore: "/api/notes/restore".into(),
});
}
items.sort_by_key(|a| std::cmp::Reverse(a.at));
items.truncate(ROWS);
Json(json!({ "items": items })).into_response()
}
pub(crate) async fn undelete_doc(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
match app.store.undelete(&id) {
Ok(true) => {
let doc = app.store.get(&id).ok().flatten();
emit(
&app,
"restored",
json!({ "id": id, "doc": doc, "waiting": waiting(&app) }),
);
Json(json!({ "ok": true, "doc": doc })).into_response()
}
Ok(false) => (
StatusCode::GONE,
Json(json!({ "error": "that document has been pruned" })),
)
.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn compare(
State(app): S,
Path((a, b)): Path<(String, String)>,
Query(v): Query<ViewQ>,
) -> Response {
let (Ok(Some(da)), Ok(Some(db))) = (app.store.get(&a), app.store.get(&b)) else {
return StatusCode::NOT_FOUND.into_response();
};
let (Ok(sa), Ok(sb)) = (app.store.source(&a), app.store.source(&b)) else {
return StatusCode::NOT_FOUND.into_response();
};
let a_name = format!("{} ({})", da.title, fmt_time(da.received_at));
let b_name = format!("{} ({})", db.title, fmt_time(db.received_at));
let unified = render::unified(&a_name, &sa, &b_name, &sb);
let html = if unified.trim().is_empty() {
"<p class=\"empty\">No changes between these two versions.</p>".to_string()
} else if v.view.as_deref() == Some("split") {
render::diff_split(&unified)
} else {
render::diff(&unified)
};
Json(json!({ "a": da, "b": db, "html": html })).into_response()
}
#[derive(Deserialize)]
pub(crate) struct PinBody {
pub(crate) pinned: bool,
}
#[derive(Deserialize)]
pub(crate) struct RenameBody {
pub(crate) name: String,
}
pub(crate) fn clean_name(raw: &str) -> Option<String> {
let name: String = raw
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
if name.is_empty() {
return None;
}
Some(name.chars().take(120).collect())
}
pub(crate) async fn rename_project(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Json(b): Json<RenameBody>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
let Some(name) = clean_name(&b.name) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a name cannot be empty" })),
)
.into_response();
};
match app.store.rename_project(id, &name) {
Ok(true) => {
emit(&app, "renamed", json!({ "project": id, "name": name }));
Json(json!({ "ok": true, "name": name })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn rename_workflow(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Json(b): Json<RenameBody>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
let Some(name) = clean_name(&b.name) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a name cannot be empty" })),
)
.into_response();
};
match app.store.rename_workflow(id, &name) {
Ok(true) => {
emit(&app, "renamed", json!({ "workflow": id, "name": name }));
Json(json!({ "ok": true, "name": name })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn queue(State(app): S, Query(q): Query<Limit>) -> Response {
match app.store.queue(q.limit.unwrap_or(QUEUE_MAX).min(QUEUE_MAX)) {
Ok(q) => Json(q).into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn mark_read(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
match app.store.mark_read(&id) {
Ok(true) => {
emit(
&app,
"read",
json!({ "ids": [id], "waiting": waiting(&app) }),
);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => Json(json!({ "ok": true })).into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn clear_queue(State(app): S, headers: HeaderMap) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
match app.store.mark_all_read() {
Ok(ids) => {
if !ids.is_empty() {
emit(&app, "read", json!({ "ids": ids, "waiting": 0 }));
}
Json(json!({ "ok": true, "n": ids.len(), "ids": ids })).into_response()
}
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct IdsBody {
pub(crate) ids: Vec<String>,
}
pub(crate) async fn unread(State(app): S, headers: HeaderMap, Json(b): Json<IdsBody>) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
match app.store.mark_unread(&b.ids) {
Ok(back) => {
if !back.is_empty() {
emit(&app, "restored", json!({ "waiting": waiting(&app) }));
}
Json(json!({ "ok": true, "n": back.len() })).into_response()
}
Err(e) => err(e),
}
}
pub(crate) async fn pin(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Json(b): Json<PinBody>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
match app.store.set_pinned(&id, b.pinned) {
Ok(true) => {
emit(&app, "pinned", json!({ "id": id, "pinned": b.pinned }));
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn receive_doc(
State(app): S,
headers: HeaderMap,
Json(payload): Json<Payload>,
) -> Response {
if !authorized(&app, &headers) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
let task = tokio::spawn(receive_and_tell(app, payload));
task.await.unwrap_or_else(|e| err(anyhow::anyhow!(e)))
}
async fn receive_and_tell(app: Arc<App>, payload: Payload) -> Response {
let app2 = app.clone();
let result = tokio::task::spawn_blocking(move || {
let large = payload
.content
.as_ref()
.is_some_and(|c| c.len() > LARGE_RENDER)
|| payload
.path
.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.is_some_and(|m| m.len() > LARGE_RENDER as u64);
let received = receive::receive(&app2.store, &app2.renderer, payload)
.map(|r| (doc_event(&app2, &r), r));
crate::platform::release_thread_memory();
(received, large)
})
.await;
let result = result.map(|(received, large)| {
if large {
tokio::task::spawn_blocking(crate::platform::release_freed_memory);
}
received
});
match result {
Ok(Ok((event, received))) => {
emit(&app, "doc", event);
let doc = received.doc;
let url = format!("{}/d/{}", config::base_url(), doc.id);
if !received.existing {
notify_desktop(&app, &doc);
}
if received.needs_full_highlight
|| (doc.kind == crate::render::Kind::Markdown
&& doc.size as usize > crate::render::HIGHLIGHT_CAP)
{
spawn_full_highlight(app.clone(), doc.id.clone(), doc.lang.clone());
}
if doc.kind == crate::render::Kind::Code {
let (app2, id, lang) = (app.clone(), doc.id.clone(), doc.lang.clone());
tokio::task::spawn_blocking(move || outline_of(&app2, &id, lang.as_deref()));
}
let status = if received.existing {
StatusCode::OK
} else {
StatusCode::CREATED
};
(
status,
Json(json!({
"id": doc.id,
"url": url,
"app_url": crate::desktop::window_installed().then(|| crate::desktop::app_url(&doc.id)),
"doc": doc,
"existing": received.existing,
"window": app.has_window(),
})),
)
.into_response()
}
Ok(Err(e)) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
pub(crate) async fn receive_aside(
State(app): S,
headers: HeaderMap,
Json(n): Json<crate::aside::NewAside>,
) -> Response {
if !authorized(&app, &headers) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
if !asides_on(&app) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "asides are off in About" })),
)
.into_response();
}
let from = crate::receive::pane_origin(&app.store, n.pane.as_deref());
match app.asides.add(n, from, crate::store::now()) {
Ok(aside) => {
emit(&app, "notes", json!({ "notes": app.asides.list() }));
(
StatusCode::CREATED,
Json(json!({ "note": aside, "window": app.has_window() })),
)
.into_response()
}
Err(e) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
pub(crate) async fn asides(State(app): S) -> Json<serde_json::Value> {
Json(json!({ "notes": app.asides.list() }))
}
pub(crate) async fn see_asides(State(app): S, headers: HeaderMap) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
if app.asides.see() {
emit(&app, "notes", json!({ "notes": app.asides.list() }));
}
Json(json!({ "ok": true })).into_response()
}
#[derive(Deserialize)]
pub(crate) struct AsideIds {
pub(crate) ids: Vec<u64>,
}
pub(crate) async fn dismiss_asides(
State(app): S,
headers: HeaderMap,
Json(b): Json<AsideIds>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
if app.asides.dismiss(&b.ids) {
emit(&app, "notes", json!({ "notes": app.asides.list() }));
}
Json(json!({ "ok": true })).into_response()
}
pub(crate) async fn restore_asides(
State(app): S,
headers: HeaderMap,
Json(b): Json<AsideIds>,
) -> Response {
if let Some(no) = refuse_reader(&app, &headers) {
return no;
}
if app.asides.restore(&b.ids) {
emit(&app, "notes", json!({ "notes": app.asides.list() }));
}
Json(json!({ "ok": true })).into_response()
}
pub(crate) const LARGE_RENDER: usize = 512 * 1024;
pub(crate) fn spawn_full_highlight(app: Arc<App>, id: String, lang: Option<String>) {
static RUNNING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(Default::default);
if !RUNNING.lock().unwrap().insert(id.clone()) {
return;
}
tokio::task::spawn_blocking(move || {
loop {
let Ok(Some(doc)) = app.store.get(&id) else {
RUNNING.lock().unwrap().remove(&id);
return;
};
let stored = full_highlight(&app, &doc, lang.as_deref());
crate::platform::release_freed_memory();
if stored {
emit(&app, "rendered", json!({ "id": id }));
}
let mut running = RUNNING.lock().unwrap();
let now = app.store.get(&id).ok().flatten().map(|d| d.content_hash);
if now.as_deref() != Some(doc.content_hash.as_str()) && now.is_some() {
drop(running);
continue;
}
running.remove(&id);
return;
}
});
}
fn full_highlight(app: &App, doc: &Doc, lang: Option<&str>) -> bool {
let id = &doc.id;
let Ok(src) = app.store.source(id) else {
return false;
};
let html = match doc.kind {
crate::render::Kind::Markdown => {
let Ok(current) = app.store.html(id) else {
return false;
};
if !crate::render::has_pending_highlight(¤t) {
return false;
}
let src = if doc.origin == "peer" {
crate::render::stayed_with(&src, &doc.sender)
} else {
src
};
let body = crate::render::strip_leading_h1(&src, &doc.title);
let base = doc.source_path.as_ref().map(|_| format!("/files/{id}/"));
app.renderer
.render_markdown_uncapped(body.as_deref().unwrap_or(&src), base.as_deref())
}
_ => app.renderer.render_code_uncapped(lang, &src),
};
matches!(
app.store.replace_html_if(id, &html, &doc.content_hash),
Ok(true)
)
}
#[derive(Deserialize)]
pub(crate) struct FolderBody {
pub(crate) doc: Option<String>,
pub(crate) root: Option<String>,
pub(crate) path: Option<String>,
pub(crate) desk: Option<i64>,
pub(crate) project: Option<i64>,
}
pub(crate) fn doc_folder(app: &App, doc: &Doc) -> Option<std::path::PathBuf> {
doc.source_path
.as_deref()
.and_then(|p| std::path::Path::new(p).parent().map(|d| d.to_path_buf()))
.into_iter()
.chain(app.store.project_root(doc.project_id).map(Into::into))
.find(|d: &std::path::PathBuf| d.is_dir())
}
pub(crate) async fn reveal(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<FolderBody>,
) -> Response {
if let Some(no) = refuse_folder(&app, &headers, &q, &b) {
return no;
}
let Some(dir) = folder_of(&app, &b) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "no folder to open" })),
)
.into_response();
};
if !platform::has_display() {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "no desktop session to open a folder in" })),
)
.into_response();
}
if platform::open_folder(&dir) {
Json(json!({ "dir": dir })).into_response()
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "nothing on this machine opens folders" })),
)
.into_response()
}
}
pub(crate) fn refuse_folder(
app: &App,
headers: &HeaderMap,
q: &std::collections::HashMap<String, String>,
b: &FolderBody,
) -> Option<Response> {
if b.desk.is_some() {
return refuse_desk(app, headers, q);
}
refuse_reader(app, headers)
}
pub(crate) fn folder_of(app: &App, b: &FolderBody) -> Option<std::path::PathBuf> {
let dir = if let Some(id) = b.root.as_deref() {
app.browse
.resolve(id, b.path.as_deref().unwrap_or(""))
.ok()
.and_then(dir_of)
} else if let Some(id) = b.doc.as_deref() {
app.store
.get(id)
.ok()
.flatten()
.and_then(|d| doc_folder(app, &d))
} else if let Some(id) = b.desk {
app.store.desk(id).ok().flatten().map(|d| d.root.into())
} else if let Some(id) = b.project {
app.store.project_root(id).map(Into::into)
} else {
None
};
dir.filter(|d| d.is_dir())
}
pub(crate) fn dir_of(p: std::path::PathBuf) -> Option<std::path::PathBuf> {
if p.is_dir() {
Some(p)
} else {
p.parent().map(|d| d.to_path_buf())
}
}