complete local markdown resource lifecycle contract
This commit is contained in:
@@ -212,26 +212,29 @@ fn spawn_local_folder_watcher(
|
||||
&root_uri_for_task,
|
||||
&relative_path,
|
||||
);
|
||||
if !is_markdown_path(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 外部文件变更→更新 BufferStore
|
||||
let document_id =
|
||||
format!("local-md:{}", encode_local_id_segment(&relative_path));
|
||||
if let Ok(ws_id) =
|
||||
local_workspace_id_from_root_uri(&root_uri_for_task)
|
||||
{
|
||||
let ws_path =
|
||||
crate::document_buffer_store::build_local_folder_workspace_path(
|
||||
&ws_id,
|
||||
&root_uri_for_task,
|
||||
&relative_path,
|
||||
&document_id,
|
||||
let document_id = if is_markdown_path(&path) {
|
||||
let document_id =
|
||||
format!("local-md:{}", encode_local_id_segment(&relative_path));
|
||||
// 外部 Markdown 变更→更新 BufferStore
|
||||
if let Ok(ws_id) =
|
||||
local_workspace_id_from_root_uri(&root_uri_for_task)
|
||||
{
|
||||
let ws_path =
|
||||
crate::document_buffer_store::build_local_folder_workspace_path(
|
||||
&ws_id,
|
||||
&root_uri_for_task,
|
||||
&relative_path,
|
||||
&document_id,
|
||||
);
|
||||
buffer_store_for_task.mark_external_modified(
|
||||
&ws_path,
|
||||
Some("external-editor".into()),
|
||||
);
|
||||
buffer_store_for_task
|
||||
.mark_external_modified(&ws_path, Some("external-editor".into()));
|
||||
}
|
||||
}
|
||||
document_id
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
@@ -279,6 +282,15 @@ fn is_markdown_path(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
fn is_local_search_index_path(path: &Path) -> bool {
|
||||
if path.components().any(|component| {
|
||||
component
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.map(|value| value == ".mnote")
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if is_markdown_path(path) {
|
||||
return true;
|
||||
}
|
||||
@@ -297,7 +309,27 @@ fn is_local_search_index_path(path: &Path) -> bool {
|
||||
.to_lowercase();
|
||||
matches!(
|
||||
extension.as_str(),
|
||||
"doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods"
|
||||
"doc"
|
||||
| "docx"
|
||||
| "odt"
|
||||
| "ppt"
|
||||
| "pptx"
|
||||
| "odp"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "ods"
|
||||
| "pdf"
|
||||
| "png"
|
||||
| "jpg"
|
||||
| "jpeg"
|
||||
| "gif"
|
||||
| "webp"
|
||||
| "svg"
|
||||
| "txt"
|
||||
| "log"
|
||||
| "csv"
|
||||
| "json"
|
||||
| "zip"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -430,8 +462,17 @@ mod tests {
|
||||
assert!(is_local_search_index_path(&std::path::Path::new(
|
||||
"office/report.xlsx"
|
||||
)));
|
||||
assert!(is_local_search_index_path(&std::path::Path::new(
|
||||
"attachments/report.pdf"
|
||||
)));
|
||||
assert!(is_local_search_index_path(&std::path::Path::new(
|
||||
"attachments/image.png"
|
||||
)));
|
||||
assert!(!is_local_search_index_path(&std::path::Path::new(
|
||||
"docs/image.png"
|
||||
".mnote/page-options.json"
|
||||
)));
|
||||
assert!(!is_local_search_index_path(&std::path::Path::new(
|
||||
"docs/.DS_Store"
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -3175,6 +3175,96 @@ pub async fn open_local_file(
|
||||
Ok((StatusCode::OK, headers, bytes))
|
||||
}
|
||||
|
||||
pub async fn stat_local_file(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFileOpenQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root_path = parse_file_root_uri(&query.root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let requested = Path::new(&query.path);
|
||||
if requested.is_absolute()
|
||||
|| requested
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_stat_root_escape",
|
||||
"本地文件路径不能越过 root",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let target = canonical_root.join(requested);
|
||||
let metadata = match fs::symlink_metadata(&target) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"rootUri": query.root_uri,
|
||||
"path": query.path,
|
||||
"exists": false,
|
||||
"sourceKind": "local_folder",
|
||||
}
|
||||
})),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_stat_failed",
|
||||
format!("无法读取本地文件状态 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
};
|
||||
let canonical_target = target.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_file_stat_failed",
|
||||
format!("无法解析本地文件路径 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
if !canonical_target.starts_with(&canonical_root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_stat_root_escape",
|
||||
"本地文件路径不能越过 root",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let file_name = canonical_target
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("资源")
|
||||
.to_string();
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"rootUri": query.root_uri,
|
||||
"path": query.path,
|
||||
"exists": true,
|
||||
"isDirectory": metadata.is_dir(),
|
||||
"fileName": file_name,
|
||||
"contentType": content_type_for_path(&canonical_target).to_str().unwrap_or("application/octet-stream"),
|
||||
"sourceKind": "local_folder",
|
||||
}
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn read_local_resource(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
|
||||
@@ -271,6 +271,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/local-folder/files/open",
|
||||
get(local_folder_source::open_local_file),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/files/stat",
|
||||
get(local_folder_source::stat_local_file),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/resource/read",
|
||||
get(local_folder_source::read_local_resource),
|
||||
|
||||
@@ -122,6 +122,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var stored = Number(window.localStorage.getItem(storageKey) || '');
|
||||
if (stored) applyWidth(stored);
|
||||
} catch (_) {}
|
||||
resizer.addEventListener('wheel', function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, { passive: false });
|
||||
resizer.addEventListener('pointerdown', function(event) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
@@ -2429,6 +2433,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
syncSidebarFileTreeSelection();
|
||||
schedulePendingLocalFolderRestoreFocus();
|
||||
restoreSidebarTreeTab();
|
||||
refreshEditorLocalAttachmentExistence();
|
||||
document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', 'projection');
|
||||
return true;
|
||||
}
|
||||
@@ -8665,6 +8670,62 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function localFileOpenRootUriFromHref(href) {
|
||||
try {
|
||||
var url = new URL(String(href || ''), window.location.origin);
|
||||
if (url.pathname !== '/api/local-folder/files/open') return '';
|
||||
return String(url.searchParams.get('rootUri') || '').trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocalFileStatusUrl(relativePath, rootUri) {
|
||||
var effectiveRootUri = String(rootUri || currentRootUri() || '').trim();
|
||||
if (!effectiveRootUri || !relativePath) return '';
|
||||
var url = new URL('/api/local-folder/files/stat', window.location.origin);
|
||||
url.searchParams.set('rootUri', effectiveRootUri);
|
||||
url.searchParams.set('path', relativePath);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function setEditorAttachmentMissingState(link, missing) {
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
var value = Boolean(missing);
|
||||
if (value) {
|
||||
link.setAttribute('data-mnote-attachment-missing', 'true');
|
||||
link.classList.add('mnote-uploaded-attachment-missing');
|
||||
link.setAttribute('aria-label', (link.textContent || '附件') + '(文件不存在)');
|
||||
} else {
|
||||
if (link.getAttribute('data-mnote-attachment-missing') !== 'true') return;
|
||||
link.removeAttribute('data-mnote-attachment-missing');
|
||||
link.classList.remove('mnote-uploaded-attachment-missing');
|
||||
link.removeAttribute('aria-label');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLocalAttachmentExistence(link) {
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
var href = link.getAttribute('href') || link.href || '';
|
||||
var localFilePath = localFileOpenPathFromHref(href);
|
||||
if (!localFilePath) return;
|
||||
var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href));
|
||||
if (!statusUrl) return;
|
||||
try {
|
||||
var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' });
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true);
|
||||
setEditorAttachmentMissingState(link, !exists);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function refreshEditorLocalAttachmentExistence() {
|
||||
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
|
||||
if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
|
||||
});
|
||||
}
|
||||
window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence;
|
||||
|
||||
function fileNameFromPath(path) {
|
||||
var value = String(path || '').trim();
|
||||
return value.indexOf('/') >= 0 ? value.split('/').pop() : value;
|
||||
@@ -8785,6 +8846,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|| Boolean(localFilePath);
|
||||
if (!shouldEnhance) return;
|
||||
if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
|
||||
void refreshLocalAttachmentExistence(link);
|
||||
return;
|
||||
}
|
||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||
@@ -10174,6 +10236,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.addEventListener('tree:resync', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
if (renderSidebarSnapshot(payload)) {
|
||||
refreshEditorLocalAttachmentExistence();
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ a:hover {
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-resizer::before {
|
||||
@@ -1871,6 +1872,12 @@ body {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"] {
|
||||
color: #9f1239 !important;
|
||||
background: #fff1f2 !important;
|
||||
box-shadow: inset 0 0 0 1px #fecdd3 !important;
|
||||
}
|
||||
|
||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before,
|
||||
.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before {
|
||||
content: "" !important;
|
||||
@@ -1883,6 +1890,10 @@ body {
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12) !important;
|
||||
}
|
||||
|
||||
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before {
|
||||
background: #e11d48 !important;
|
||||
}
|
||||
|
||||
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-word::before {
|
||||
background: #4f7df3;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user