2026-05-08 23:15:00 +08:00
|
|
|
use crate::app::AppState;
|
2026-05-08 11:23:08 +08:00
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
use crate::routes::local_folder_source::decode_local_id_segment;
|
2026-05-08 23:15:00 +08:00
|
|
|
use axum::extract::{Extension, Query, State};
|
2026-05-08 11:23:08 +08:00
|
|
|
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
|
|
|
|
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
|
|
|
|
use futures_util::stream;
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use std::convert::Infallible;
|
2026-05-08 23:15:00 +08:00
|
|
|
use std::path::PathBuf;
|
2026-05-08 11:23:08 +08:00
|
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
2026-05-08 23:15:00 +08:00
|
|
|
use tokio::sync::broadcast::error::RecvError;
|
2026-05-08 11:23:08 +08:00
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct LocalFolderEventsQuery {
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
pub document_id: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn local_folder_events(
|
2026-05-08 23:15:00 +08:00
|
|
|
State(state): State<AppState>,
|
2026-05-08 11:23:08 +08:00
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<LocalFolderEventsQuery>,
|
|
|
|
|
) -> Result<
|
|
|
|
|
(
|
|
|
|
|
HeaderMap,
|
|
|
|
|
Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>,
|
|
|
|
|
),
|
|
|
|
|
WebError,
|
|
|
|
|
> {
|
|
|
|
|
let root = parse_file_root_uri(&query.root_uri)?;
|
|
|
|
|
let canonical_root = root.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
let document_relative_path = query
|
|
|
|
|
.document_id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.and_then(local_markdown_relative_path_from_document_id);
|
2026-05-08 23:15:00 +08:00
|
|
|
let subscription = state
|
|
|
|
|
.local_folder_watcher_registry()
|
|
|
|
|
.subscribe(&canonical_root)
|
|
|
|
|
.map_err(|error| WebError::internal(error).with_context(&context))?;
|
2026-05-08 11:23:08 +08:00
|
|
|
|
|
|
|
|
let initial = json!({
|
|
|
|
|
"sourceKind": "local_folder",
|
2026-05-08 23:15:00 +08:00
|
|
|
"rootUri": subscription.root_uri(),
|
2026-05-08 11:23:08 +08:00
|
|
|
"documentId": query.document_id,
|
|
|
|
|
"revision": system_time_ms(SystemTime::now()),
|
|
|
|
|
});
|
2026-05-08 23:15:00 +08:00
|
|
|
let stream = stream::unfold(
|
|
|
|
|
(Some(initial), subscription, document_relative_path),
|
|
|
|
|
|(initial, mut subscription, document_relative_path)| async move {
|
2026-05-08 11:23:08 +08:00
|
|
|
if let Some(payload) = initial {
|
2026-05-08 23:15:00 +08:00
|
|
|
return Some((
|
|
|
|
|
Ok(stream_event("ready", &payload)),
|
|
|
|
|
(None, subscription, document_relative_path),
|
|
|
|
|
));
|
2026-05-08 11:23:08 +08:00
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
loop {
|
|
|
|
|
match subscription.receiver.recv().await {
|
|
|
|
|
Ok(payload) => {
|
|
|
|
|
if let Some(expected) = document_relative_path.as_deref() {
|
|
|
|
|
let relative_path = payload
|
|
|
|
|
.get("relativePath")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if expected != relative_path {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Some((
|
|
|
|
|
Ok(stream_event("change", &payload)),
|
|
|
|
|
(None, subscription, document_relative_path),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Err(RecvError::Lagged(_)) => continue,
|
|
|
|
|
Err(RecvError::Closed) => return None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-08 11:23:08 +08:00
|
|
|
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
|
|
|
|
|
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
|
|
|
|
}
|
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") {
|
|
|
|
|
headers.insert(name, HeaderValue::from_static("rust-web"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok((
|
|
|
|
|
headers,
|
|
|
|
|
Sse::new(stream).keep_alive(
|
|
|
|
|
KeepAlive::new()
|
|
|
|
|
.interval(Duration::from_secs(15))
|
|
|
|
|
.text("keepalive"),
|
|
|
|
|
),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_file_root_uri(root_uri: &str) -> Result<PathBuf, WebError> {
|
|
|
|
|
let trimmed = root_uri.trim();
|
|
|
|
|
let Some(path) = trimmed.strip_prefix("file://") else {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_root_invalid",
|
|
|
|
|
"本地文件夹 rootUri 必须是 file:// URI",
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
Ok(PathBuf::from(path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
|
|
|
|
let trimmed = document_id.trim();
|
|
|
|
|
let encoded = trimmed.strip_prefix("local-md:")?;
|
|
|
|
|
decode_local_id_segment(encoded).ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn system_time_ms(time: SystemTime) -> u128 {
|
|
|
|
|
time.duration_since(UNIX_EPOCH)
|
|
|
|
|
.map(|duration| duration.as_millis())
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
|
|
|
|
|
let id = payload
|
|
|
|
|
.get("revision")
|
|
|
|
|
.and_then(|value| {
|
|
|
|
|
value
|
|
|
|
|
.as_str()
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.or_else(|| value.as_u64().map(|number| number.to_string()))
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_else(|| "0".to_string());
|
|
|
|
|
SseEvent::default()
|
|
|
|
|
.event(event_name)
|
|
|
|
|
.id(id)
|
|
|
|
|
.json_data(payload)
|
|
|
|
|
.expect("SSE 事件必须可序列化")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_document_id_maps_to_relative_path() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
local_markdown_relative_path_from_document_id("local-md:docs~2FREADME.md")
|
|
|
|
|
.as_deref(),
|
|
|
|
|
Some("docs/README.md")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-08 23:15:00 +08:00
|
|
|
fn parse_file_root_uri_requires_file_scheme() {
|
|
|
|
|
assert!(parse_file_root_uri("file:///tmp/example").is_ok());
|
|
|
|
|
assert!(parse_file_root_uri("/tmp/example").is_err());
|
2026-05-08 11:23:08 +08:00
|
|
|
}
|
|
|
|
|
}
|