推进本地索引与 AI changed-files 审计

- 为本地工作区补充 search/backlinks/tags/resource 引用索引与 watcher 单文件刷新
- 在页面设置中增加索引页签,并展示本地反链、标签和 AI changed-files 审计
- 补充本地搜索与本地 AI changed-files 浏览器 smoke,并回填当前优先级 checklist
This commit is contained in:
lix-2026
2026-05-19 10:22:01 +08:00
parent 1b5d6a2a2d
commit fc4a47e597
8 changed files with 968 additions and 42 deletions
@@ -1,3 +1,4 @@
use crate::routes::{local_workspace_id_from_root_uri, refresh_local_search_index_for_path};
use notify::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value};
@@ -191,12 +192,20 @@ fn spawn_local_folder_watcher(
continue;
}
for path in event.paths {
if !is_markdown_path(&path) {
if !is_local_search_index_path(&path) {
continue;
}
let Some(relative_path) = relative_path_string(&canonical_root, &path) else {
continue;
};
refresh_local_search_index_for_event(
&canonical_root,
&root_uri_for_task,
&relative_path,
);
if !is_markdown_path(&path) {
continue;
}
let payload = json!({
"sourceKind": "local_folder",
"rootUri": root_uri_for_task,
@@ -215,6 +224,13 @@ fn spawn_local_folder_watcher(
Ok((sender, shutdown_tx))
}
fn refresh_local_search_index_for_event(root: &Path, root_uri: &str, relative_path: &str) {
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
return;
};
let _ = refresh_local_search_index_for_path(root, root_uri, &workspace_id, relative_path);
}
fn canonical_root_uri(root: &Path) -> String {
format!("file://{}", root.display())
}
@@ -235,6 +251,29 @@ fn is_markdown_path(path: &Path) -> bool {
.unwrap_or(false)
}
fn is_local_search_index_path(path: &Path) -> bool {
if is_markdown_path(path) {
return true;
}
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
if file_name.ends_with(".mindmap.json") {
return true;
}
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_lowercase();
matches!(
extension.as_str(),
"doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods"
)
}
fn should_emit_event_kind(kind: &EventKind) -> bool {
match kind {
EventKind::Create(_) | EventKind::Remove(_) => true,
@@ -276,7 +315,10 @@ fn system_time_ms(time: SystemTime) -> u128 {
#[cfg(test)]
mod tests {
use super::{should_emit_event_kind, LocalFolderWatcherRegistry};
use super::{
is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind,
LocalFolderWatcherRegistry,
};
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
use notify::EventKind;
@@ -348,4 +390,42 @@ mod tests {
AccessKind::Read
)));
}
#[test]
fn watcher_index_path_filter_accepts_markdown_and_resources() {
assert!(is_local_search_index_path(&std::path::Path::new(
"docs/page.md"
)));
assert!(is_local_search_index_path(&std::path::Path::new(
"maps/idea.mindmap.json"
)));
assert!(is_local_search_index_path(&std::path::Path::new(
"office/report.xlsx"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
"docs/image.png"
)));
}
#[test]
fn watcher_event_refreshes_local_search_index_path() {
let root = test_root("search-index-event");
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("README.md"), "# Home\n").expect("write home");
std::fs::write(
root.join("docs").join("watched.md"),
"---\ntitle: Watched\n---\n# Watched\nWatcherToken\n",
)
.expect("write watched");
let root_uri = format!("file://{}", root.display());
refresh_local_search_index_for_event(&root, &root_uri, "docs/watched.md");
let index_path = root.join(".mnote").join("index").join("search-index.json");
let index = std::fs::read_to_string(&index_path).expect("index exists");
assert!(index.contains("docs/watched.md"));
assert!(index.contains("WatcherToken"));
let _ = std::fs::remove_dir_all(root);
}
}
@@ -5,7 +5,7 @@ use crate::routes::local_markdown_parser::{
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;
@@ -18,6 +18,8 @@ struct LocalSearchIndex {
root_uri: String,
workspace_id: String,
documents: Vec<LocalSearchDocument>,
#[serde(default)]
resources: Vec<LocalSearchResource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -33,6 +35,16 @@ struct LocalSearchDocument {
updated_at: u128,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalSearchResource {
resource_id: String,
resource_type: String,
title: String,
path: String,
updated_at: u128,
}
pub(crate) fn query_local_search_index(
root_path: &Path,
root_uri: &str,
@@ -66,6 +78,17 @@ pub(crate) fn query_local_search_index(
break;
}
}
if page_id.is_none() && results.len() < limit.max(1) as usize {
for resource in index.resources.iter() {
if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_resource_projection(resource, root_uri));
if results.len() >= limit.max(1) as usize {
break;
}
}
}
Ok(json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
@@ -75,7 +98,8 @@ pub(crate) fn query_local_search_index(
"rootUri": index.root_uri,
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len()
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
},
"recentChanges": recent_changes,
"results": results
@@ -93,7 +117,65 @@ pub(crate) fn refresh_local_search_index(
"rootUri": index.root_uri,
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len()
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
}))
}
pub(crate) fn refresh_local_search_index_for_path(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
relative_path: &str,
) -> Result<Value, WebError> {
let relative_path = normalize_index_relative_path(relative_path)?;
let mut index = match read_local_search_index(root_path) {
Ok(Some(index))
if index.version == LOCAL_SEARCH_INDEX_VERSION
&& index.root_uri == root_uri
&& index.workspace_id == workspace_id =>
{
index
}
Ok(_) | Err(_) => {
return refresh_local_search_index(root_path, root_uri, workspace_id);
}
};
index
.documents
.retain(|document| document.path != relative_path);
index
.resources
.retain(|resource| resource.path != relative_path);
let absolute_path = root_path.join(&relative_path);
if absolute_path.exists() && absolute_path.is_file() && is_markdown_path(&absolute_path) {
index
.documents
.push(index_markdown_file(root_path, &absolute_path)?);
} else if absolute_path.exists()
&& absolute_path.is_file()
&& resource_type_from_path(&absolute_path).is_some()
{
index
.resources
.push(index_resource_file(root_path, &absolute_path)?);
}
index
.documents
.sort_by(|left, right| left.path.cmp(&right.path));
index
.resources
.sort_by(|left, right| left.path.cmp(&right.path));
index.built_at = now_ms();
write_local_search_index(root_path, &index)?;
Ok(json!({
"version": index.version,
"rootUri": index.root_uri,
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
}))
}
@@ -195,23 +277,76 @@ fn rebuild_local_search_index(
workspace_id: &str,
) -> Result<LocalSearchIndex, WebError> {
let mut documents = Vec::new();
collect_markdown_documents(root_path, root_path, &mut documents)?;
let mut resources = Vec::new();
collect_markdown_documents(root_path, root_path, &mut documents, &mut resources)?;
documents.sort_by(|left, right| left.path.cmp(&right.path));
resources.sort_by(|left, right| left.path.cmp(&right.path));
let index = LocalSearchIndex {
version: LOCAL_SEARCH_INDEX_VERSION,
built_at: now_ms(),
root_uri: root_uri.to_string(),
workspace_id: workspace_id.to_string(),
documents,
resources,
};
write_local_search_index(root_path, &index)?;
Ok(index)
}
fn read_local_search_index(root_path: &Path) -> Result<Option<LocalSearchIndex>, WebError> {
let index_path = root_path
.join(".mnote")
.join("index")
.join("search-index.json");
if !index_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&index_path).map_err(|error| {
WebError::bad_request_code(
"local_search_index_read_failed",
format!("无法读取本地搜索索引 {}: {error}", index_path.display()),
)
})?;
serde_json::from_str::<LocalSearchIndex>(&content)
.map(Some)
.map_err(|error| {
WebError::bad_request_code(
"local_search_index_invalid",
format!("本地搜索索引格式非法 {}: {error}", index_path.display()),
)
})
}
fn normalize_index_relative_path(relative_path: &str) -> Result<String, WebError> {
let normalized = relative_path.trim().replace('\\', "/");
if normalized.is_empty() {
return Err(WebError::bad_request_code(
"local_search_index_path_required",
"本地搜索索引更新缺少相对路径",
));
}
let path = PathBuf::from(&normalized);
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(WebError::bad_request_code(
"local_search_index_path_escape",
"本地搜索索引相对路径不能越过 root",
));
}
Ok(normalized)
}
fn collect_markdown_documents(
root_path: &Path,
current: &Path,
documents: &mut Vec<LocalSearchDocument>,
resources: &mut Vec<LocalSearchResource>,
) -> Result<(), WebError> {
let entries = match fs::read_dir(current) {
Ok(entries) => entries,
@@ -244,13 +379,17 @@ fn collect_markdown_documents(
)
})?;
if file_type.is_dir() {
collect_markdown_documents(root_path, &path, documents)?;
collect_markdown_documents(root_path, &path, documents, resources)?;
continue;
}
if !file_type.is_file() || !is_markdown_path(&path) {
if !file_type.is_file() {
continue;
}
documents.push(index_markdown_file(root_path, &path)?);
if is_markdown_path(&path) {
documents.push(index_markdown_file(root_path, &path)?);
} else if resource_type_from_path(&path).is_some() {
resources.push(index_resource_file(root_path, &path)?);
}
}
Ok(())
}
@@ -303,6 +442,45 @@ fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocum
})
}
fn index_resource_file(root_path: &Path, path: &Path) -> Result<LocalSearchResource, WebError> {
let resource_type = resource_type_from_path(path).ok_or_else(|| {
WebError::bad_request_code(
"local_search_index_resource_type_unsupported",
format!("不支持索引该资源文件 {}", path.display()),
)
})?;
let relative_path = path
.strip_prefix(root_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let title = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("resource")
.trim()
.trim_end_matches(".mindmap")
.to_string();
let metadata = fs::metadata(path).map_err(|error| {
WebError::bad_request_code(
"local_search_index_stat_failed",
format!("无法读取本地资源索引文件状态 {}: {error}", path.display()),
)
})?;
Ok(LocalSearchResource {
resource_id: format!("local-resource:{}", relative_path.replace('/', "~2F")),
resource_type: resource_type.to_string(),
title,
path: relative_path,
updated_at: metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis())
.unwrap_or_default(),
})
}
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
let index_dir = root_path.join(".mnote").join("index");
fs::create_dir_all(&index_dir).map_err(|error| {
@@ -349,6 +527,30 @@ fn local_search_document_matches(
}
}
fn local_search_resource_matches(
resource: &LocalSearchResource,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&resource.title)
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
resource.title, resource.path, resource.resource_type
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
}
}
fn local_search_document_projection(
document: &LocalSearchDocument,
root_uri: &str,
@@ -371,6 +573,20 @@ fn local_search_document_projection(
})
}
fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &str) -> Value {
json!({
"id": resource.resource_id,
"documentId": resource.resource_id,
"title": resource.title,
"path": resource.path,
"resourceType": resource.resource_type,
"sourceKind": "local_folder",
"rootUri": root_uri,
"updatedAt": resource.updated_at,
"publicPath": format!("/tree?sourceKind=local_folder&rootUri={}", root_uri)
})
}
fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value {
let mut sorted = documents.iter().collect::<Vec<_>>();
sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
@@ -522,6 +738,26 @@ fn is_markdown_path(path: &Path) -> bool {
.unwrap_or(false)
}
fn resource_type_from_path(path: &Path) -> Option<&'static str> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
if file_name.ends_with(".mindmap.json") {
return Some("mindmap");
}
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
match extension.as_str() {
"doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" => Some("office"),
_ => None,
}
}
fn normalize_search_text(value: &str) -> String {
value.trim().to_lowercase()
}
@@ -558,6 +794,14 @@ mod tests {
"---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n",
)
.expect("write child");
fs::create_dir_all(root.join("maps")).expect("create maps");
fs::create_dir_all(root.join("office")).expect("create office");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"title":"Idea Map","nodes":[]}"#,
)
.expect("write mindmap");
fs::write(root.join("office").join("report.xlsx"), b"office bytes").expect("write office");
let projection = query_local_search_index(
&root,
@@ -610,6 +854,92 @@ mod tests {
.join("index")
.join("search-index.json")
.exists());
let mindmap_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
"local-ws-test",
"idea.mindmap",
None,
10,
false,
false,
)
.expect("mindmap projection");
assert!(mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")));
let office_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
"local-ws-test",
"report.xlsx",
None,
10,
false,
false,
)
.expect("office projection");
assert!(office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx")));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_index_incrementally_updates_single_markdown_path() {
let root = temp_root("mnote-local-search-index-incremental");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-incremental";
fs::write(root.join("README.md"), "# Home\nRoot body.\n").expect("write home");
fs::write(
root.join("docs").join("child.md"),
"---\ntitle: Child\n---\n# Child\nOriginal body.\n",
)
.expect("write child");
refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
fs::write(
root.join("docs").join("child.md"),
"---\ntitle: Child Updated\n---\n# Child Updated\nChangedToken body.\n",
)
.expect("update child");
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
.expect("incremental update");
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let child = index
.documents
.iter()
.find(|document| document.path == "docs/child.md")
.expect("child document");
assert_eq!(child.title, "Child Updated");
assert!(child.raw_text.contains("ChangedToken"));
fs::remove_file(root.join("docs").join("child.md")).expect("remove child");
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
.expect("incremental remove");
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/child.md"));
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let _ = fs::remove_dir_all(&root);
}
}
+3 -2
View File
@@ -31,9 +31,10 @@ pub(crate) mod web_shell;
mod ws;
pub(crate) use local_folder_source::{
ensure_local_path_read_access, ensure_local_workspace_access, update_local_markdown_title,
update_local_page_options, write_local_markdown_page_body,
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
update_local_markdown_title, update_local_page_options, write_local_markdown_page_body,
};
pub(crate) use local_search_index::refresh_local_search_index_for_path;
use crate::app::AppState;
use axum::routing::{any, delete, get, post, put};
+167 -1
View File
@@ -67,7 +67,14 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiSessionSearchResults: [],
pageAiSessionSearchTimer: 0,
pageAiSessionError: '',
pageAiPermissionRequests: []
pageAiPermissionRequests: [],
localIndexSummary: {
scopeKey: '',
loading: false,
error: '',
backlinks: null,
tags: null
}
};
function closestAction(target, selector) {
@@ -6497,6 +6504,7 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page"></button>' +
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom"></button>' +
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="index"></button>' +
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global"></button>' +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
@@ -6512,6 +6520,19 @@ const SIDEBAR_TREE_JS: &str = r##"
createPageOptionRow('hideChildPages', 'checkbox') +
createPageOptionRow('showBlockRefCount', 'checkbox') +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="index" hidden>' +
'<div class="wolai-page-settings-index-panel" data-testid="wolai-page-settings-local-index-panel">' +
'<div class="wolai-page-settings-index-status" data-testid="wolai-page-settings-local-index-status"></div>' +
'<section class="wolai-page-settings-index-group">' +
'<div class="wolai-page-settings-index-title"></div>' +
'<div class="wolai-page-settings-index-list" data-testid="wolai-page-settings-local-index-backlinks"></div>' +
'</section>' +
'<section class="wolai-page-settings-index-group">' +
'<div class="wolai-page-settings-index-title"></div>' +
'<div class="wolai-page-settings-index-list" data-testid="wolai-page-settings-local-index-tags"></div>' +
'</section>' +
'</div>' +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
createGlobalHeadingNumbersRow() +
'</div>' +
@@ -6528,6 +6549,149 @@ const SIDEBAR_TREE_JS: &str = r##"
return popover;
}
function pageSettingsLocalIndexScopeKey() {
return [
currentSourceKind(),
currentRootUri(),
resolveWorkspaceId(document.body),
currentDocumentId()
].join('|');
}
function pageSettingsLocalIndexIsAvailable() {
return currentSourceKind() === 'local_folder' && Boolean(currentRootUri()) && Boolean(currentDocumentId());
}
function pageSettingsLocalIndexEmpty(message) {
return '<div class="wolai-page-settings-index-empty">' + escapeHtml(message) + '</div>';
}
function renderPageSettingsLocalIndexList(items, kind) {
var rows = Array.isArray(items) ? items : [];
if (!rows.length) {
return pageSettingsLocalIndexEmpty(kind === 'backlinks' ? '' : '');
}
if (kind === 'backlinks') {
return rows.slice(0, 12).map(function(item) {
var title = searchText(item && item.title) || searchText(item && item.path) || '';
var path = searchText(item && item.path);
var snippet = searchText(item && item.snippet);
return '' +
'<div class="wolai-page-settings-index-row">' +
'<div class="wolai-page-settings-index-row-title">' + escapeHtml(title) + '</div>' +
(path ? '<div class="wolai-page-settings-index-row-meta">' + escapeHtml(path) + '</div>' : '') +
(snippet ? '<div class="wolai-page-settings-index-row-snippet">' + escapeHtml(snippet) + '</div>' : '') +
'</div>';
}).join('');
}
return rows.slice(0, 16).map(function(item) {
var tag = searchText(item && item.tag) || 'untagged';
var count = Number(item && item.count || 0);
return '' +
'<div class="wolai-page-settings-index-row is-tag">' +
'<div class="wolai-page-settings-index-row-title">#' + escapeHtml(tag) + '</div>' +
'<div class="wolai-page-settings-index-row-meta">' + count + ' </div>' +
'</div>';
}).join('');
}
function renderPageSettingsLocalIndex(popover) {
popover = popover || ensurePageSettingsPopover();
var statusNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
var backlinksNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
var tagsNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
if (!(statusNode instanceof HTMLElement) || !(backlinksNode instanceof HTMLElement) || !(tagsNode instanceof HTMLElement)) return;
if (!pageSettingsLocalIndexIsAvailable()) {
statusNode.textContent = '';
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty(' root');
tagsNode.innerHTML = pageSettingsLocalIndexEmpty(' root');
return;
}
var summary = pageUiState.localIndexSummary || {};
if (summary.loading) {
statusNode.textContent = '...';
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('');
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('');
return;
}
if (summary.error) {
statusNode.textContent = '';
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error);
tagsNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error);
return;
}
if (summary.scopeKey !== pageSettingsLocalIndexScopeKey()) {
statusNode.textContent = '';
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('');
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('');
return;
}
statusNode.textContent = ' root .mnote/index/search-index.json';
backlinksNode.innerHTML = renderPageSettingsLocalIndexList(summary.backlinks, 'backlinks');
tagsNode.innerHTML = renderPageSettingsLocalIndexList(summary.tags, 'tags');
}
async function loadPageSettingsLocalIndex(force) {
if (!pageSettingsLocalIndexIsAvailable()) {
renderPageSettingsLocalIndex();
return;
}
var scopeKey = pageSettingsLocalIndexScopeKey();
var current = pageUiState.localIndexSummary || {};
if (!force && current.scopeKey === scopeKey && !current.error && !current.loading) {
renderPageSettingsLocalIndex();
return;
}
pageUiState.localIndexSummary = {
scopeKey: scopeKey,
loading: true,
error: '',
backlinks: null,
tags: null
};
renderPageSettingsLocalIndex();
try {
var baseParams = new URLSearchParams();
baseParams.set('workspaceId', resolveWorkspaceId(document.body));
baseParams.set('rootUri', currentRootUri());
var backlinksParams = new URLSearchParams(baseParams);
backlinksParams.set('documentId', currentDocumentId());
var backlinksUrl = '/api/search/local-index/backlinks?' + backlinksParams.toString();
var tagsUrl = '/api/search/local-index/tags?' + baseParams.toString();
var responses = await Promise.all([
fetch(backlinksUrl, { headers: { accept: 'application/json' } }),
fetch(tagsUrl, { headers: { accept: 'application/json' } })
]);
var backlinksPayload = await responses[0].json().catch(function(){ return null; });
var tagsPayload = await responses[1].json().catch(function(){ return null; });
if (!responses[0].ok || !backlinksPayload || backlinksPayload.ok !== true) {
throw new Error('backlinks_' + responses[0].status);
}
if (!responses[1].ok || !tagsPayload || tagsPayload.ok !== true) {
throw new Error('tags_' + responses[1].status);
}
pageUiState.localIndexSummary = {
scopeKey: scopeKey,
loading: false,
error: '',
backlinks: backlinksPayload.result && Array.isArray(backlinksPayload.result.backlinks) ? backlinksPayload.result.backlinks : [],
tags: tagsPayload.result && Array.isArray(tagsPayload.result.tags) ? tagsPayload.result.tags : []
};
} catch (error) {
pageUiState.localIndexSummary = {
scopeKey: scopeKey,
loading: false,
error: error instanceof Error ? error.message : String(error),
backlinks: [],
tags: []
};
}
renderPageSettingsLocalIndex();
}
function renderPageSettingsPopover() {
var popover = ensurePageSettingsPopover();
var options = currentPageOptions();
@@ -6551,6 +6715,7 @@ const SIDEBAR_TREE_JS: &str = r##"
'<span> ' + Number(stats.blockCount || 0) + '</span>' +
'<span> ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
}
renderPageSettingsLocalIndex(popover);
}
function setActivePageSettingsTab(tabName) {
@@ -6563,6 +6728,7 @@ const SIDEBAR_TREE_JS: &str = r##"
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
});
if (tabName === 'index') void loadPageSettingsLocalIndex(false);
}
async function persistPageOptionsPatch(patch) {
+70
View File
@@ -2835,6 +2835,76 @@ body {
display: none !important;
}
.wolai-page-settings-index-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.wolai-page-settings-index-status {
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.wolai-page-settings-index-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.wolai-page-settings-index-title {
color: #1B1C1C;
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.wolai-page-settings-index-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.wolai-page-settings-index-row,
.wolai-page-settings-index-empty {
padding: 8px 10px;
border-radius: 8px;
background: #F7F6F4;
}
.wolai-page-settings-index-row {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.wolai-page-settings-index-row.is-tag {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wolai-page-settings-index-row-title {
min-width: 0;
overflow: hidden;
color: #1B1C1C;
font-size: 13px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-settings-index-row-meta,
.wolai-page-settings-index-row-snippet,
.wolai-page-settings-index-empty {
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.wolai-page-setting-row {
min-height: 48px;
display: flex;