feat: integrate pi rust lab runtime
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -755,12 +755,6 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
}
|
||||
|
||||
function closeAdminAccessPolicyDialog() {
|
||||
var existing = document.querySelector('[data-testid="mnote-admin-access-policy-modal"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
document.documentElement.removeAttribute('data-mnote-admin-access-policy-modal-open');
|
||||
}
|
||||
|
||||
function accountMenuFallbackSession() {
|
||||
var body = document.body instanceof HTMLElement ? document.body : null;
|
||||
var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : '';
|
||||
@@ -876,43 +870,6 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
|
||||
if (closeButton instanceof HTMLElement) closeButton.focus();
|
||||
}
|
||||
|
||||
function openAdminAccessPolicyDialog(trigger) {
|
||||
closeAdminAccessPolicyDialog();
|
||||
closeAccountMenu();
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
var role = trigger.getAttribute('data-access-policy-role') === 'admin' ? 'admin' : 'user';
|
||||
var template = document.querySelector('[data-testid="mnote-admin-access-policy-template-' + role + '"]');
|
||||
var dialog = document.createElement('div');
|
||||
dialog.className = 'mnote-admin-policy-modal';
|
||||
dialog.setAttribute('data-testid', 'mnote-admin-access-policy-modal');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dialog.setAttribute('aria-labelledby', 'mnote-admin-policy-dialog-title');
|
||||
dialog.innerHTML =
|
||||
'<div class="mnote-admin-policy-modal__backdrop" data-admin-access-policy-close></div>' +
|
||||
'<section class="mnote-admin-policy-modal__panel">' +
|
||||
'<button type="button" class="mnote-admin-policy-modal__close" data-admin-access-policy-close aria-label="关闭授权管理"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||
'<div class="mnote-admin-policy-modal__content" data-testid="mnote-admin-access-policy-modal-content"></div>' +
|
||||
'</section>';
|
||||
var content = dialog.querySelector('[data-testid="mnote-admin-access-policy-modal-content"]');
|
||||
if (content) {
|
||||
content.innerHTML = template ? template.innerHTML : '<div class="mnote-admin-policy-empty">授权管理面板加载失败</div>';
|
||||
}
|
||||
if (typeof window.MNOTEInitAccessPolicyPanel === 'function') {
|
||||
window.MNOTEInitAccessPolicyPanel(dialog);
|
||||
}
|
||||
dialog.querySelectorAll('[data-admin-access-policy-close]').forEach(function(button) {
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeAdminAccessPolicyDialog();
|
||||
});
|
||||
});
|
||||
document.body.appendChild(dialog);
|
||||
document.documentElement.setAttribute('data-mnote-admin-access-policy-modal-open', 'true');
|
||||
var closeButton = dialog.querySelector('.mnote-admin-policy-modal__close');
|
||||
if (closeButton instanceof HTMLElement) closeButton.focus();
|
||||
}
|
||||
|
||||
async function signOutAccount(trigger) {
|
||||
setCommandPending(trigger, true);
|
||||
try {
|
||||
@@ -949,8 +906,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.innerHTML =
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-profile" role="menuitem"><span class="material-symbols-outlined" data-icon="account_circle" aria-hidden="true"></span><span>个人信息</span></button>' +
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span>授权管理</span></button>' +
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-ai-management" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="smart_toy" aria-hidden="true"></span><span>AI 管理</span></button>' +
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-ai-management" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="auto_awesome" aria-hidden="true"></span><span>AI 管理</span></button>' +
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退出登录</button>' +
|
||||
'<div class="mnote-account-menu__error" data-account-error hidden></div>';
|
||||
var sessionPromise = fetchAccountSession();
|
||||
@@ -961,18 +917,6 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
|
||||
sessionPromise.then(openProfileDialog);
|
||||
});
|
||||
}
|
||||
var accessPolicyLink = menu.querySelector('[data-testid="mnote-account-access-policy"]');
|
||||
sessionPromise.then(function(session) {
|
||||
if (!(accessPolicyLink instanceof HTMLElement)) return;
|
||||
accessPolicyLink.hidden = false;
|
||||
accessPolicyLink.setAttribute('data-access-policy-role', sessionIsAdmin(session) ? 'admin' : 'user');
|
||||
});
|
||||
if (accessPolicyLink) {
|
||||
accessPolicyLink.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
openAdminAccessPolicyDialog(accessPolicyLink);
|
||||
});
|
||||
}
|
||||
var aiManagementLink = menu.querySelector('[data-testid="mnote-account-ai-management"]');
|
||||
sessionPromise.then(function(session) {
|
||||
if (!(aiManagementLink instanceof HTMLElement)) return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{
|
||||
AppendAiRuntimeEventInput, CreatePasswordIdentityInput, DirectoryGrantInput,
|
||||
UpsertAiRuntimeRunInput, UpsertUserInput, UpsertWorkspaceInput,
|
||||
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertUserInput, UpsertWorkspaceInput,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -70,6 +70,25 @@ enum DevSeedOperation {
|
||||
#[serde(default)]
|
||||
events: Vec<DevSeedRuntimeEvent>,
|
||||
},
|
||||
SeedAiPolicy {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "userId")]
|
||||
user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "workspaceId")]
|
||||
workspace_id: Option<String>,
|
||||
#[serde(default = "empty_json_array")]
|
||||
#[serde(alias = "allowedRootsJson")]
|
||||
allowed_roots_json: Value,
|
||||
#[serde(default = "empty_json_object")]
|
||||
#[serde(alias = "modelPolicyJson")]
|
||||
model_policy_json: Value,
|
||||
#[serde(default = "empty_json_object")]
|
||||
#[serde(alias = "quotaJson")]
|
||||
quota_json: Value,
|
||||
},
|
||||
ClearRuntimeEvents {
|
||||
user_id: String,
|
||||
run_id: String,
|
||||
@@ -141,6 +160,10 @@ fn empty_json_object() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
fn empty_json_array() -> Value {
|
||||
json!([])
|
||||
}
|
||||
|
||||
fn default_dev_seed_limit() -> usize {
|
||||
200
|
||||
}
|
||||
@@ -305,6 +328,30 @@ fn apply_seed_operation(state: &AppState, operation: DevSeedOperation) -> Result
|
||||
"events": event_records,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::SeedAiPolicy {
|
||||
id,
|
||||
user_id,
|
||||
workspace_id,
|
||||
allowed_roots_json,
|
||||
model_policy_json,
|
||||
quota_json,
|
||||
} => {
|
||||
let policy = state
|
||||
.control_plane()
|
||||
.upsert_ai_policy(UpsertAiPolicyInput {
|
||||
id,
|
||||
user_id,
|
||||
workspace_id,
|
||||
allowed_roots_json: allowed_roots_json.to_string(),
|
||||
model_policy_json: model_policy_json.to_string(),
|
||||
quota_json: quota_json.to_string(),
|
||||
})
|
||||
.map_err(dev_seed_error)?;
|
||||
Ok(json!({
|
||||
"kind": "seedAiPolicy",
|
||||
"policy": policy,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::ClearRuntimeEvents { user_id, run_id } => {
|
||||
let deleted = state
|
||||
.control_plane()
|
||||
@@ -592,4 +639,75 @@ mod tests {
|
||||
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
|
||||
assert_eq!(payload["results"][0]["run"]["status"], "running");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_seed_ai_policy_writes_to_control_plane() {
|
||||
let router = test_router(true);
|
||||
|
||||
let (status, payload) = send_seed(
|
||||
router.clone(),
|
||||
json!({
|
||||
"seeds": [
|
||||
{
|
||||
"kind": "setupWorkspace",
|
||||
"user_id": "dev-seed-policy-user",
|
||||
"workspace_id": "dev-seed-policy-ws",
|
||||
"workspace_name": "策略测试空间",
|
||||
"root_uri": "file:///tmp/dev-seed-policy",
|
||||
"root_path": "/tmp/dev-seed-policy"
|
||||
},
|
||||
{
|
||||
"kind": "seedAiPolicy",
|
||||
"id": "dev-seed-ai-policy",
|
||||
"userId": "dev-seed-policy-user",
|
||||
"workspaceId": "dev-seed-policy-ws",
|
||||
"allowedRootsJson": [{ "rootUri": "file:///tmp/dev-seed-policy" }],
|
||||
"modelPolicyJson": {
|
||||
"defaultModel": "omniroute/pi-fast",
|
||||
"allowedModels": ["omniroute/pi-fast"],
|
||||
"skills": {
|
||||
"smoke-skill": {
|
||||
"name": "Smoke Skill",
|
||||
"enabled": true,
|
||||
"description": "seeded by dev fixture",
|
||||
"source": "/tmp/mnote-pi-smoke-skill/SKILL.md",
|
||||
"riskLevel": "low",
|
||||
"requiredScopes": []
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"smoke-mcp": {
|
||||
"name": "Smoke MCP",
|
||||
"enabled": true,
|
||||
"transport": "stdio",
|
||||
"command": "node /tmp/mnote-pi-smoke-mcp/server.mjs",
|
||||
"url": "",
|
||||
"networkPolicy": "deny-all",
|
||||
"secretRefs": [],
|
||||
"facadeOnly": true,
|
||||
"sandbox": true,
|
||||
"description": "seeded by dev fixture",
|
||||
"riskLevel": "low",
|
||||
"requiredScopes": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"quotaJson": { "daily": 10 }
|
||||
}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "seedAiPolicy 应成功: {payload}");
|
||||
assert_eq!(payload["results"][1]["kind"], "seedAiPolicy");
|
||||
assert_eq!(payload["results"][1]["policy"]["id"], "dev-seed-ai-policy");
|
||||
let model_policy: Value = serde_json::from_str(
|
||||
payload["results"][1]["policy"]["model_policy_json"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
)
|
||||
.expect("model policy json");
|
||||
assert_eq!(model_policy["skills"]["smoke-skill"]["enabled"], true);
|
||||
assert_eq!(model_policy["mcpServers"]["smoke-mcp"]["enabled"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::provider_identity_sync::sync_provider_identities;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_status_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
load_local_trash_entries,
|
||||
create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state,
|
||||
is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot,
|
||||
load_local_folder_page_tree_snapshot, load_local_trash_entries,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
@@ -197,28 +196,11 @@ pub async fn admin_access_policy_entry(
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let share_grants_path = control_plane_status_display();
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} share_grants_path={share_grants_path} is_admin=true />
|
||||
});
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>授权管理</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="admin" data-mnote-actor-id="{}">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
content
|
||||
))
|
||||
.into_response();
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/admin/ai#ai-admin-access")
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
Ok(response)
|
||||
}
|
||||
@@ -236,27 +218,11 @@ pub async fn user_access_policy_entry(
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
return Ok(response);
|
||||
}
|
||||
let workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} is_admin=false />
|
||||
});
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>授权管理</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="user-access-policy" data-mnote-actor-id="{}">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
content
|
||||
))
|
||||
.into_response();
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/user/ai#ai-admin-access")
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
Ok(response)
|
||||
}
|
||||
@@ -285,6 +251,26 @@ pub async fn admin_ai_entry(
|
||||
ai_management_response(&state, &context, true)
|
||||
}
|
||||
|
||||
pub async fn settings_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Response, WebError> {
|
||||
if !has_real_auth_context(&state, &context) {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/auth")
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
return Ok(response);
|
||||
}
|
||||
ai_management_response(
|
||||
&state,
|
||||
&context,
|
||||
is_local_access_policy_admin_context(&context),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn user_ai_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -313,7 +299,7 @@ fn ai_management_response(
|
||||
is_admin={is_admin}
|
||||
/>
|
||||
});
|
||||
let title = if is_admin { "AI 管理" } else { "AI 设置" };
|
||||
let title = "MNote 设置";
|
||||
let shell = if is_admin {
|
||||
"admin-ai"
|
||||
} else {
|
||||
@@ -3144,10 +3130,11 @@ mod tests {
|
||||
.expect("body");
|
||||
let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8");
|
||||
assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
||||
assert!(!admin_html.contains(r#"data-testid="mnote-account-access-policy""#));
|
||||
assert!(admin_html.contains(r#"data-mnote-action="open-account-menu""#));
|
||||
assert!(admin_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
|
||||
assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
|
||||
assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));
|
||||
assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
|
||||
assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));
|
||||
|
||||
let user_response =
|
||||
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
@@ -3168,10 +3155,11 @@ mod tests {
|
||||
.expect("body");
|
||||
let user_html = String::from_utf8(user_body.to_vec()).expect("utf8");
|
||||
assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
||||
assert!(!user_html.contains(r#"data-testid="mnote-account-access-policy""#));
|
||||
assert!(user_html.contains(r#"data-mnote-action="open-account-menu""#));
|
||||
assert!(user_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
|
||||
assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
|
||||
assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));
|
||||
assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
|
||||
assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3269,18 +3257,11 @@ mod tests {
|
||||
.await
|
||||
.expect("admin response");
|
||||
|
||||
assert_eq!(admin_response.status(), StatusCode::OK);
|
||||
let body = to_bytes(admin_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#));
|
||||
assert!(html.contains("文件夹授权"));
|
||||
assert!(html.contains("管理员可以授权任意本地文件夹"));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
|
||||
assert_eq!(admin_response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
admin_response.headers().get(header::LOCATION).unwrap(),
|
||||
"/admin/ai#ai-admin-access"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3297,19 +3278,11 @@ mod tests {
|
||||
.await
|
||||
.expect("user access policy response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-mnote-shell="user-access-policy""#));
|
||||
assert!(html.contains("文件夹授权"));
|
||||
assert!(html.contains("普通用户只能授权自己空间下的文件夹"));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#));
|
||||
assert!(!html.contains("仅管理员可见"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get(header::LOCATION).unwrap(),
|
||||
"/user/ai#ai-admin-access"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1512,6 +1512,7 @@ pub async fn query_rag(
|
||||
"mode": mode.clone(),
|
||||
"top_k": body.top_k,
|
||||
"chunk_top_k": body.chunk_top_k,
|
||||
"enable_rerank": false,
|
||||
"include_references": true,
|
||||
"include_chunk_content": body.include_chunk_content.unwrap_or(true),
|
||||
})),
|
||||
@@ -1650,6 +1651,7 @@ pub async fn search(
|
||||
"mode": search_mode.clone(),
|
||||
"top_k": body.top_k,
|
||||
"chunk_top_k": body.chunk_top_k,
|
||||
"enable_rerank": false,
|
||||
"include_references": true,
|
||||
"include_chunk_content": body.include_chunk_content.unwrap_or(true),
|
||||
})),
|
||||
|
||||
@@ -56,6 +56,11 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
|
||||
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
#[cfg(not(test))]
|
||||
static LOCAL_SEARCH_INDEX_CONTROL_PLANE: OnceLock<
|
||||
std::sync::Arc<dyn control_plane::ControlPlaneStore>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn local_page_tree_snapshot_cache(
|
||||
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
@@ -3363,13 +3368,26 @@ fn save_local_markdown_page_inner(
|
||||
}
|
||||
|
||||
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
|
||||
let control_plane = open_control_plane_store();
|
||||
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
|
||||
control_plane.as_ref(),
|
||||
root,
|
||||
root_uri,
|
||||
workspace_id,
|
||||
);
|
||||
#[cfg(test)]
|
||||
{
|
||||
let control_plane = open_control_plane_store();
|
||||
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
|
||||
control_plane.as_ref(),
|
||||
root,
|
||||
root_uri,
|
||||
workspace_id,
|
||||
);
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let control_plane = LOCAL_SEARCH_INDEX_CONTROL_PLANE.get_or_init(open_control_plane_store);
|
||||
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
|
||||
control_plane.as_ref(),
|
||||
root,
|
||||
root_uri,
|
||||
workspace_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn local_markdown_conflict_error(
|
||||
@@ -4605,13 +4623,20 @@ fn add_local_file_operation_contract(root_uri: &str, action: &str, mut result: V
|
||||
let previous_relative_path = local_tree_previous_relative_path(&result);
|
||||
let document_id = local_tree_result_string(&result, "documentId")
|
||||
.or_else(|| local_tree_result_string(&result, "id"));
|
||||
let resource_kind = local_tree_result_string(&result, "resourceKind");
|
||||
let previous_document_id =
|
||||
local_tree_result_string(&result, "previousDocumentId").or_else(|| {
|
||||
previous_relative_path
|
||||
.as_deref()
|
||||
.map(local_markdown_path_page_id)
|
||||
if matches!(
|
||||
resource_kind.as_deref(),
|
||||
Some("markdown") | Some("markdown_bundle")
|
||||
) {
|
||||
document_id.clone()
|
||||
} else {
|
||||
previous_relative_path
|
||||
.as_deref()
|
||||
.map(local_markdown_path_page_id)
|
||||
}
|
||||
});
|
||||
let resource_kind = local_tree_result_string(&result, "resourceKind");
|
||||
let affected_parents = add_local_tree_command_affected_parents(&result, &resolved_action);
|
||||
let resource = relative_path.as_deref().and_then(|path| {
|
||||
local_file_operation_resource(path, document_id.clone(), resource_kind.as_deref())
|
||||
@@ -5529,6 +5554,15 @@ fn move_local_directory(
|
||||
}
|
||||
|
||||
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
if let Some(idempotent) = idempotent_deleted_local_directory(root, entry_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
if let Some(idempotent) = idempotent_deleted_local_markdown_bundle_directory(root, entry_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
if let Some(idempotent) = idempotent_deleted_local_raw_file(root, entry_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
if let Some(directory) = resolve_local_directory_id(root, entry_id)? {
|
||||
// 若目录是页面 bundle(包含同名 .md),按 Markdown 页面生命周期入 trash
|
||||
if let Some(main_md) = nested_bundle_main_markdown(&directory) {
|
||||
@@ -5552,6 +5586,12 @@ fn restore_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
|
||||
return restore_local_raw_file(root, entry_id);
|
||||
}
|
||||
if let Some(idempotent) = idempotent_restored_local_directory(root, entry_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
if let Some(idempotent) = idempotent_restored_local_raw_file(root, entry_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
restore_local_markdown_page(root, entry_id)
|
||||
}
|
||||
|
||||
@@ -5563,6 +5603,12 @@ fn purge_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
|
||||
return purge_local_raw_file(root, entry_id);
|
||||
}
|
||||
if let Some(idempotent) = idempotent_purged_local_directory(root, entry_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
if let Some(idempotent) = idempotent_purged_local_raw_file(root, entry_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
if entry_id.starts_with("local:asset:") || entry_id.starts_with("local:node:") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
@@ -5574,13 +5620,20 @@ fn purge_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
|
||||
fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let markdown_file =
|
||||
find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
let markdown_file = match find_markdown_by_page_id(root, &metadata, document_id)? {
|
||||
Some(markdown_file) => markdown_file,
|
||||
None => {
|
||||
if let Some(idempotent) =
|
||||
idempotent_deleted_local_markdown_page(root, &metadata, document_id)?
|
||||
{
|
||||
return Ok(idempotent);
|
||||
}
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_markdown_not_found",
|
||||
"找不到要删除的本地 Markdown 页面",
|
||||
)
|
||||
})?;
|
||||
));
|
||||
}
|
||||
};
|
||||
let trash_dir = root.join(".mnote").join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -5660,6 +5713,95 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
}))
|
||||
}
|
||||
|
||||
fn idempotent_deleted_local_markdown_page(
|
||||
root: &Path,
|
||||
metadata: &LocalFolderMetadata,
|
||||
document_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(trash_entry) = metadata.trash_entries.get(document_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if trash_entry.resource_kind != "markdown" && trash_entry.resource_kind != "markdown_bundle" {
|
||||
return Ok(None);
|
||||
}
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
|
||||
let trash_exists = if trash_entry.resource_kind == "markdown_bundle" {
|
||||
trash_path.is_dir()
|
||||
} else {
|
||||
trash_path.is_file()
|
||||
};
|
||||
if !trash_exists {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": document_id,
|
||||
"documentId": document_id,
|
||||
"resourceKind": trash_entry.resource_kind,
|
||||
"originalRelativePath": trash_entry.original_relative_path,
|
||||
"trashPath": trash_entry.trash_relative_path,
|
||||
"action": "delete",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn idempotent_deleted_local_markdown_bundle_directory(
|
||||
root: &Path,
|
||||
entry_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(relative_path) = local_directory_relative_path_from_entry_id(entry_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
let Some((_, trash_entry)) = metadata.trash_entries.iter().find(|(_, entry)| {
|
||||
entry.resource_kind == "markdown_bundle" && entry.original_relative_path == relative_path
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
|
||||
if !trash_path.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": trash_entry.document_id,
|
||||
"resourceKind": "markdown_bundle",
|
||||
"resourceScope": "local_folder",
|
||||
"originalRelativePath": trash_entry.original_relative_path,
|
||||
"originalFilePath": local_trash_original_path(trash_entry),
|
||||
"trashEntryId": trash_entry.trash_entry_id,
|
||||
"trashPath": trash_entry.trash_relative_path,
|
||||
"action": "delete",
|
||||
"canonicalCommand": "tree.resource.archive",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn idempotent_purged_local_markdown_page(
|
||||
root: &Path,
|
||||
document_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(relative_path) = local_markdown_relative_path_from_document_id(document_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let markdown_path = resolve_metadata_relative_path(root, &relative_path)?;
|
||||
if markdown_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": document_id,
|
||||
"documentId": document_id,
|
||||
"originalRelativePath": relative_path,
|
||||
"action": "purge",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let relative_path = normalize_relative_path(root, file)?;
|
||||
@@ -5725,6 +5867,39 @@ fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Valu
|
||||
}))
|
||||
}
|
||||
|
||||
fn idempotent_deleted_local_raw_file(
|
||||
root: &Path,
|
||||
entry_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
let Some(trash_key) = find_local_file_trash_entry_key(&metadata, entry_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(trash_entry) = metadata.trash_entries.get(&trash_key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trash_relative_path = local_trash_file_path(trash_entry);
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||
if !trash_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"originalRelativePath": trash_entry.original_relative_path,
|
||||
"originalFilePath": local_trash_original_path(trash_entry),
|
||||
"trashEntryId": trash_key,
|
||||
"trashPath": trash_relative_path,
|
||||
"action": "delete",
|
||||
"canonicalCommand": "tree.resource.archive",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Result<Value, WebError> {
|
||||
if directory == root {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -5801,16 +5976,18 @@ fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Resul
|
||||
|
||||
fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_entry = metadata
|
||||
.trash_entries
|
||||
.get(document_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
let trash_entry = match metadata.trash_entries.get(document_id).cloned() {
|
||||
Some(trash_entry) => trash_entry,
|
||||
None => {
|
||||
if let Some(idempotent) = idempotent_restored_local_markdown_page(root, document_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要恢复的本地回收站记录",
|
||||
)
|
||||
})?;
|
||||
));
|
||||
}
|
||||
};
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
|
||||
if trash_entry.resource_kind == "markdown_bundle" {
|
||||
return restore_local_markdown_bundle(
|
||||
@@ -5868,6 +6045,33 @@ fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result<Value,
|
||||
}))
|
||||
}
|
||||
|
||||
fn idempotent_restored_local_markdown_page(
|
||||
root: &Path,
|
||||
document_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(relative_path) = local_markdown_relative_path_from_document_id(document_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let markdown_path = resolve_metadata_relative_path(root, &relative_path)?;
|
||||
if !markdown_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let restored_document_id = local_markdown_path_page_id(&relative_path);
|
||||
if restored_document_id != document_id {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": restored_document_id,
|
||||
"documentId": restored_document_id,
|
||||
"relativePath": relative_path,
|
||||
"previousDocumentId": document_id,
|
||||
"action": "restore",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn restore_local_markdown_bundle(
|
||||
root: &Path,
|
||||
document_id: &str,
|
||||
@@ -6004,6 +6208,34 @@ fn restore_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError
|
||||
}))
|
||||
}
|
||||
|
||||
fn idempotent_restored_local_raw_file(
|
||||
root: &Path,
|
||||
entry_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(relative_path) = local_file_relative_path_from_entry_id(entry_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let original_path = resolve_metadata_relative_path(root, &relative_path)?;
|
||||
if !original_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let trash_entry_id = local_file_trash_entry_id(&relative_path);
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"originalFilePath": relative_path,
|
||||
"relativePath": normalize_relative_path(root, &original_path)?,
|
||||
"trashEntryId": trash_entry_id,
|
||||
"action": "restore",
|
||||
"canonicalCommand": "tree.resource.restore",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn restore_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_key = find_local_directory_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
||||
@@ -6073,6 +6305,116 @@ fn restore_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebErro
|
||||
}))
|
||||
}
|
||||
|
||||
fn idempotent_deleted_local_directory(
|
||||
root: &Path,
|
||||
entry_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
let Some(trash_key) = find_local_directory_trash_entry_key(&metadata, entry_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(trash_entry) = metadata.trash_entries.get(&trash_key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trash_relative_path = local_trash_file_path(trash_entry);
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||
if !trash_path.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_directory",
|
||||
"resourceScope": "local_folder",
|
||||
"originalRelativePath": trash_entry.original_relative_path,
|
||||
"originalFilePath": local_trash_original_path(trash_entry),
|
||||
"trashEntryId": trash_key,
|
||||
"trashPath": trash_relative_path,
|
||||
"action": "delete",
|
||||
"canonicalCommand": "tree.resource.archive",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn local_directory_relative_path_from_entry_id(entry_id: &str) -> Option<String> {
|
||||
let trimmed = entry_id.trim();
|
||||
if let Some(relative_path) = trimmed.strip_prefix("local-dir-trash:") {
|
||||
return Some(relative_path.to_string());
|
||||
}
|
||||
if let Some(encoded) = trimmed.strip_prefix("local-dir:") {
|
||||
return decode_local_id_segment(encoded).ok();
|
||||
}
|
||||
trimmed
|
||||
.strip_prefix("local:node:")
|
||||
.or_else(|| trimmed.strip_prefix("local:folder:"))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn local_file_relative_path_from_entry_id(entry_id: &str) -> Option<String> {
|
||||
let trimmed = entry_id.trim();
|
||||
trimmed
|
||||
.strip_prefix("local-file:")
|
||||
.or_else(|| trimmed.strip_prefix("local:asset:"))
|
||||
.or_else(|| trimmed.strip_prefix("local:node:"))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn idempotent_restored_local_directory(
|
||||
root: &Path,
|
||||
entry_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(relative_path) = local_directory_relative_path_from_entry_id(entry_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let original_path = resolve_metadata_relative_path(root, &relative_path)?;
|
||||
if !original_path.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
let trash_entry_id = local_directory_trash_entry_id(&relative_path);
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_directory",
|
||||
"resourceScope": "local_folder",
|
||||
"relativePath": relative_path,
|
||||
"trashEntryId": trash_entry_id,
|
||||
"action": "restore",
|
||||
"canonicalCommand": "tree.resource.restore",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn idempotent_purged_local_directory(
|
||||
root: &Path,
|
||||
entry_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(relative_path) = local_directory_relative_path_from_entry_id(entry_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let original_path = resolve_metadata_relative_path(root, &relative_path)?;
|
||||
if original_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let trash_entry_id = local_directory_trash_entry_id(&relative_path);
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_directory",
|
||||
"resourceScope": "local_folder",
|
||||
"originalRelativePath": relative_path,
|
||||
"trashEntryId": trash_entry_id,
|
||||
"action": "purge",
|
||||
"canonicalCommand": "tree.resource.purge",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? {
|
||||
@@ -6108,12 +6450,18 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
"sourceKind": "local_folder",
|
||||
}));
|
||||
}
|
||||
let trash_entry = metadata.trash_entries.remove(document_id).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_markdown_not_found",
|
||||
"找不到要永久删除的本地 Markdown 页面",
|
||||
)
|
||||
})?;
|
||||
let trash_entry = match metadata.trash_entries.remove(document_id) {
|
||||
Some(trash_entry) => trash_entry,
|
||||
None => {
|
||||
if let Some(idempotent) = idempotent_purged_local_markdown_page(root, document_id)? {
|
||||
return Ok(idempotent);
|
||||
}
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_markdown_not_found",
|
||||
"找不到要永久删除的本地 Markdown 页面",
|
||||
));
|
||||
}
|
||||
};
|
||||
remove_file_order_path(
|
||||
&mut metadata.file_order,
|
||||
&trash_entry.original_relative_path,
|
||||
@@ -6194,6 +6542,33 @@ fn purge_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError>
|
||||
}))
|
||||
}
|
||||
|
||||
fn idempotent_purged_local_raw_file(
|
||||
root: &Path,
|
||||
entry_id: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
let Some(relative_path) = local_file_relative_path_from_entry_id(entry_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let original_path = resolve_metadata_relative_path(root, &relative_path)?;
|
||||
if original_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let trash_entry_id = local_file_trash_entry_id(&relative_path);
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"originalRelativePath": relative_path,
|
||||
"trashEntryId": trash_entry_id,
|
||||
"action": "purge",
|
||||
"canonicalCommand": "tree.resource.purge",
|
||||
"sourceKind": "local_folder",
|
||||
"idempotent": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
||||
@@ -12223,6 +12598,50 @@ fn main() {}
|
||||
Some("local-dir-trash:Folder")
|
||||
);
|
||||
|
||||
let repeated_delete =
|
||||
execute_local_tree_command(&root_uri, "delete", &folder_id, None, None)
|
||||
.expect("repeated folder delete should be idempotent");
|
||||
assert_eq!(repeated_delete["idempotent"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
repeated_delete["trashEntryId"].as_str(),
|
||||
Some("local-dir-trash:Folder")
|
||||
);
|
||||
|
||||
let restored = execute_local_tree_command(&root_uri, "restore", &folder_id, None, None)
|
||||
.expect("restore folder");
|
||||
assert_eq!(restored["resourceKind"].as_str(), Some("local_directory"));
|
||||
assert!(root
|
||||
.join("Folder")
|
||||
.join("Nested")
|
||||
.join("note.txt")
|
||||
.is_file());
|
||||
|
||||
let repeated_restore =
|
||||
execute_local_tree_command(&root_uri, "restore", "local-dir-trash:Folder", None, None)
|
||||
.expect("repeated folder restore should be idempotent");
|
||||
assert_eq!(repeated_restore["idempotent"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
repeated_restore["resourceKind"].as_str(),
|
||||
Some("local_directory")
|
||||
);
|
||||
assert!(root
|
||||
.join("Folder")
|
||||
.join("Nested")
|
||||
.join("note.txt")
|
||||
.is_file());
|
||||
|
||||
execute_local_tree_command(&root_uri, "delete", &folder_id, None, None)
|
||||
.expect("re-delete folder");
|
||||
let purged =
|
||||
execute_local_tree_command(&root_uri, "purge", "local-dir-trash:Folder", None, None)
|
||||
.expect("purge folder");
|
||||
assert_eq!(purged["resourceKind"].as_str(), Some("local_directory"));
|
||||
assert!(!root.join(".mnote").join("trash").join("Folder").exists());
|
||||
let repeated_purge =
|
||||
execute_local_tree_command(&root_uri, "purge", "local-dir-trash:Folder", None, None)
|
||||
.expect("repeated folder purge should be idempotent");
|
||||
assert_eq!(repeated_purge["idempotent"].as_bool(), Some(true));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -12256,6 +12675,45 @@ fn main() {}
|
||||
assert!(root.join(trash_path).join("Page.md").is_file());
|
||||
assert!(root.join(trash_path).join("asset.txt").is_file());
|
||||
|
||||
let repeated_delete =
|
||||
execute_local_tree_command(&root_uri, "delete", &folder_id, None, None)
|
||||
.expect("repeated folder-row delete should be idempotent");
|
||||
assert_eq!(repeated_delete["idempotent"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
repeated_delete["resourceKind"].as_str(),
|
||||
Some("markdown_bundle")
|
||||
);
|
||||
assert!(
|
||||
root.join(trash_path).join("Page.md").is_file(),
|
||||
"重复删除不应破坏回收站中的页面 bundle"
|
||||
);
|
||||
|
||||
let restored = execute_local_tree_command(&root_uri, "restore", &page_id, None, None)
|
||||
.expect("restore page bundle");
|
||||
assert_eq!(restored["documentId"].as_str(), Some(page_id.as_str()));
|
||||
assert!(root.join("Page").join("Page.md").is_file());
|
||||
assert!(root.join("Page").join("asset.txt").is_file());
|
||||
|
||||
let repeated_restore =
|
||||
execute_local_tree_command(&root_uri, "restore", &page_id, None, None)
|
||||
.expect("repeated restore should be idempotent");
|
||||
assert_eq!(repeated_restore["idempotent"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
repeated_restore["documentId"].as_str(),
|
||||
Some(page_id.as_str())
|
||||
);
|
||||
assert!(root.join("Page").join("Page.md").is_file());
|
||||
|
||||
execute_local_tree_command(&root_uri, "delete", &page_id, None, None)
|
||||
.expect("re-delete page bundle");
|
||||
let purged = execute_local_tree_command(&root_uri, "purge", &page_id, None, None)
|
||||
.expect("purge page bundle");
|
||||
assert_eq!(purged["documentId"].as_str(), Some(page_id.as_str()));
|
||||
assert!(!root.join(".mnote").join("trash").join("Page").exists());
|
||||
let repeated_purge = execute_local_tree_command(&root_uri, "purge", &page_id, None, None)
|
||||
.expect("repeated page purge should be idempotent");
|
||||
assert_eq!(repeated_purge["idempotent"].as_bool(), Some(true));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -12287,6 +12745,37 @@ fn main() {}
|
||||
.expect("trash index");
|
||||
assert!(trash_index.contains("local-file:docs/photo.png"));
|
||||
|
||||
let repeated_delete = execute_local_tree_command(
|
||||
&root_uri,
|
||||
"delete",
|
||||
"local-file:docs/photo.png",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("repeated local-file delete should be idempotent");
|
||||
assert_eq!(repeated_delete["idempotent"].as_bool(), Some(true));
|
||||
|
||||
let restored = execute_local_tree_command(
|
||||
&root_uri,
|
||||
"restore",
|
||||
"local-file:docs/photo.png",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("restore local-file asset");
|
||||
assert_eq!(restored["resourceKind"].as_str(), Some("local_file"));
|
||||
assert!(root.join("docs").join("photo.png").is_file());
|
||||
|
||||
let repeated_restore = execute_local_tree_command(
|
||||
&root_uri,
|
||||
"restore",
|
||||
"local-file:docs/photo.png",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("repeated local-file restore should be idempotent");
|
||||
assert_eq!(repeated_restore["idempotent"].as_bool(), Some(true));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -12325,6 +12814,9 @@ fn main() {}
|
||||
.expect("purge chinese-named asset");
|
||||
assert_eq!(purged["ok"].as_bool(), Some(true));
|
||||
assert!(!root.join(".mnote").join("trash").join("资源.ext").exists());
|
||||
let repeated_purge = execute_local_tree_command(&root_uri, "purge", asset_id, None, None)
|
||||
.expect("repeated asset purge should be idempotent");
|
||||
assert_eq!(repeated_purge["idempotent"].as_bool(), Some(true));
|
||||
let trash_index_after_purge =
|
||||
std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
|
||||
.expect("trash index after purge");
|
||||
|
||||
@@ -77,6 +77,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/", get(gateway::root_entry))
|
||||
.route("/trash", get(gateway::trash_entry))
|
||||
.route("/favicon.ico", get(gateway::favicon))
|
||||
.route("/settings", get(gateway::settings_entry))
|
||||
.route(
|
||||
"/admin/access-policy",
|
||||
get(gateway::admin_access_policy_entry),
|
||||
@@ -600,11 +601,21 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
|
||||
.route("/api/page-ai/pi/send", post(page_ai_pi::send))
|
||||
.route("/api/page-ai/pi/abort", post(page_ai_pi::abort))
|
||||
.route("/api/page-ai/pi/ui-response", post(page_ai_pi::ui_response))
|
||||
.route(
|
||||
"/api/page-ai/pi/ui-request-bridge",
|
||||
post(page_ai_pi::ui_request_bridge),
|
||||
)
|
||||
.route("/api/page-ai/pi/events", get(page_ai_pi::events))
|
||||
.route("/api/page-ai/pi/sessions", get(page_ai_pi::list_sessions))
|
||||
.route(
|
||||
"/api/page-ai/pi/sessions",
|
||||
get(page_ai_pi::list_sessions).delete(page_ai_pi::clear_sessions),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/pi/sessions/{session_id}",
|
||||
get(page_ai_pi::get_session_history),
|
||||
get(page_ai_pi::get_session_history)
|
||||
.patch(page_ai_pi::rename_session)
|
||||
.delete(page_ai_pi::delete_session_history),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/pi/sessions/{session_id}/events",
|
||||
@@ -615,6 +626,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/page-ai/pi/tool-call-bridge",
|
||||
post(page_ai_pi::tool_call_bridge),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/pi/mcp-call-bridge",
|
||||
post(page_ai_pi::mcp_call_bridge),
|
||||
)
|
||||
.route("/page-ai/pi", get(page_ai_pi::shell))
|
||||
.route(
|
||||
"/api/sidebar/shortcuts",
|
||||
@@ -679,6 +694,23 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/ai-admin/users/{user_id}/settings",
|
||||
get(ai_settings::admin_get_user_settings).put(ai_settings::admin_put_user_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/directory-access-requests",
|
||||
get(ai_settings::user_directory_access_requests)
|
||||
.post(ai_settings::create_directory_access_request),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/directory-access-requests",
|
||||
get(ai_settings::admin_directory_access_requests),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/directory-access-requests/{request_id}/approve",
|
||||
post(ai_settings::approve_directory_access_request),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/directory-access-requests/{request_id}/reject",
|
||||
post(ai_settings::reject_directory_access_request),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/share-links",
|
||||
get(local_folder_source::get_share_links).post(local_folder_source::create_share_link),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3570,6 +3570,70 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_page_bundle_folder_delete_is_idempotent() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-page-bundle-folder-delete-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("Bundle")).expect("create bundle");
|
||||
std::fs::write(root.join("Bundle").join("Bundle.md"), "# Bundle\n").expect("write page");
|
||||
std::fs::write(root.join("Bundle").join("asset.txt"), "asset").expect("write asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let folder_id = format!(
|
||||
"local-dir:{}",
|
||||
crate::routes::local_folder_source::encode_local_id_segment("Bundle")
|
||||
);
|
||||
|
||||
for attempt in 0..2 {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"archive","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{folder_id}"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let status = response.status();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"attempt={attempt} body={}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(
|
||||
payload["result"]["execution"]["resourceKind"].as_str(),
|
||||
Some("markdown_bundle")
|
||||
);
|
||||
if attempt == 1 {
|
||||
assert_eq!(
|
||||
payload["result"]["execution"]["idempotent"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!root.join("Bundle").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("Bundle")
|
||||
.join("Bundle.md")
|
||||
.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
|
||||
|
||||
use crate::ssr::pages::admin::AdminAccessPolicyPanel;
|
||||
use leptos::prelude::*;
|
||||
|
||||
const SIDEBAR_TREE_JS: &str = r##"
|
||||
@@ -112,13 +111,6 @@ pub fn PageLayout(
|
||||
"views": ["page-tree", "file-tree"]
|
||||
})
|
||||
.to_string();
|
||||
let admin_access_policy_template = crate::ssr::render_view(leptos::view! {
|
||||
<AdminAccessPolicyPanel workspace_name={ws_name.clone()} is_admin=true boot_script=false />
|
||||
});
|
||||
let user_access_policy_template = crate::ssr::render_view(leptos::view! {
|
||||
<AdminAccessPolicyPanel workspace_name={ws_name.clone()} is_admin=false boot_script=false />
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
|
||||
@@ -160,9 +152,6 @@ pub fn PageLayout(
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
|
||||
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
|
||||
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
|
||||
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
|
||||
<script type="module" src={browser_runtime_src("mnote-ui-runtime.js")}></script>
|
||||
<script type="module" src={browser_runtime_src("resource-open-runtime.js")}></script>
|
||||
<script type="module" src={browser_runtime_src("local-upload-runtime.js")}></script>
|
||||
|
||||
@@ -102,6 +102,7 @@ a:hover {
|
||||
.material-symbols-outlined[data-icon="delete"]::before { content: "⌫"; }
|
||||
.material-symbols-outlined[data-icon="right_panel_open"]::before,
|
||||
.material-symbols-outlined[data-icon="open_in_new"]::before { content: "↗"; }
|
||||
.material-symbols-outlined[data-icon="arrow_outward"]::before { content: "↗"; }
|
||||
.material-symbols-outlined[data-icon="share"]::before { content: "⇪"; }
|
||||
.material-symbols-outlined[data-icon="link"]::before { content: "∞"; }
|
||||
.material-symbols-outlined[data-icon="content_copy"]::before,
|
||||
@@ -109,6 +110,29 @@ a:hover {
|
||||
.material-symbols-outlined[data-icon="tag"]::before { content: "#"; }
|
||||
.material-symbols-outlined[data-icon="edit"]::before { content: "✎"; }
|
||||
.material-symbols-outlined[data-icon="add"]::before { content: "+"; }
|
||||
.material-symbols-outlined[data-icon="autorenew"]::before,
|
||||
.material-symbols-outlined[data-icon="sync"]::before { content: "↻"; }
|
||||
.material-symbols-outlined[data-icon="chevron_right"]::before { content: "›"; }
|
||||
.material-symbols-outlined[data-icon="chevron_left"]::before { content: "‹"; }
|
||||
.material-symbols-outlined[data-icon="chat"]::before,
|
||||
.material-symbols-outlined[data-icon="comment"]::before,
|
||||
.material-symbols-outlined[data-icon="mode_comment"]::before { content: "▢"; }
|
||||
.material-symbols-outlined[data-icon="palette"]::before { content: "◐"; }
|
||||
.material-symbols-outlined[data-icon="format_align_center"]::before { content: "≡"; }
|
||||
.material-symbols-outlined[data-icon="translate"]::before { content: "文"; }
|
||||
.material-symbols-outlined[data-icon="dashboard"]::before { content: "▦"; }
|
||||
.material-symbols-outlined[data-icon="description"]::before,
|
||||
.material-symbols-outlined[data-icon="article"]::before,
|
||||
.material-symbols-outlined[data-icon="notes"]::before { content: "▤"; }
|
||||
.material-symbols-outlined[data-icon="format_list_bulleted"]::before { content: "☷"; }
|
||||
.material-symbols-outlined[data-icon="checklist"]::before { content: "☑"; }
|
||||
.material-symbols-outlined[data-icon="text_fields"]::before { content: "T"; }
|
||||
.material-symbols-outlined[data-icon="function"]::before { content: "ƒ"; }
|
||||
.material-symbols-outlined[data-icon="looks_one"]::before { content: "1"; }
|
||||
.material-symbols-outlined[data-icon="looks_two"]::before { content: "2"; }
|
||||
.material-symbols-outlined[data-icon="looks_3"]::before { content: "3"; }
|
||||
.material-symbols-outlined[data-icon="looks_4"]::before { content: "4"; }
|
||||
.material-symbols-outlined[data-icon="check"]::before { content: "✓"; }
|
||||
.material-symbols-outlined[data-icon="manage_search"]::before { content: "⌕"; }
|
||||
.material-symbols-outlined[data-icon="subdirectory_arrow_right"]::before { content: "↳"; }
|
||||
|
||||
@@ -134,19 +158,26 @@ a:hover {
|
||||
.material-symbols-outlined[data-icon="drive_file_move"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 6h6l2 2h8v10H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m13 12 3 3-3 3M8 15h8' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="delete"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 6h14M9 6V4h6v2M8 6l1 14h6l1-14M10.5 10v6M13.5 10v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="right_panel_open"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v14H4zM14 5v14M8 12h6M11 9l3 3-3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="autorenew"],
|
||||
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19 8a7 7 0 0 0-11.8-2.8L5 7.5M5 4v3.5h3.5M5 16a7 7 0 0 0 11.8 2.8L19 16.5M19 20v-3.5h-3.5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="chevron_right"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m9 5 7 7-7 7' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="chevron_left"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m15 5-7 7 7 7' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="preview"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Ccircle cx='12' cy='12' r='2.8' fill='none' stroke='black' stroke-width='2'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="download"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 4v10M8.5 11.5 12 15l3.5-3.5M5 19h14' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="notes"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 5h12v14H6zM9 9h6M9 12h6M9 15h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="arrow_outward"],
|
||||
.material-symbols-outlined[data-icon="open_in_new"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 6H5v13h13v-3M12 5h7v7M10 14 19 5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="share"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='18' cy='5' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='6' cy='12' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='19' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m8.7 10.7 6.6-4.4M8.7 13.3l6.6 4.4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
|
||||
.material-symbols-outlined[data-icon="comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="chat"],
|
||||
.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="travel_explore"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='11' cy='11' r='7' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M4 11h14M11 4a10 10 0 0 1 0 14M11 4a10 10 0 0 0 0 14M16.5 16.5 21 21' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M7.5 8.5h2.5l1 1.5 2.5-.5 1 2-2 1 .5 2.5h-3l-1.5-2-2 .5' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="document_scanner"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 3H5a2 2 0 0 0-2 2v2M17 3h2a2 2 0 0 1 2 2v2M7 21H5a2 2 0 0 1-2-2v-2M17 21h2a2 2 0 0 0 2-2v-2M7 8h10M7 12h10M7 16h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="article"],
|
||||
.material-symbols-outlined[data-icon="description"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="drive_file_rename_outline"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m4 17-.5 3.5L7 20l11-11-3-3zM13 8l3 3M5 6h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="format_paint"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 7h10l2 3-2 3H5l-2-3zM9 13v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
@@ -160,6 +191,72 @@ a:hover {
|
||||
.material-symbols-outlined[data-icon="account_circle"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='12' cy='9' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M6.8 19a5.4 5.4 0 0 1 10.4 0' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="admin_panel_settings"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3 5 6v5c0 4.5 2.9 8 7 10 4.1-2 7-5.5 7-10V6z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m9 12 2 2 4-5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="close"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 6 18 18M18 6 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="backspace"]::before,
|
||||
.material-symbols-outlined[data-icon="check"]::before,
|
||||
.material-symbols-outlined[data-icon="checklist"]::before,
|
||||
.material-symbols-outlined[data-icon="code"]::before,
|
||||
.material-symbols-outlined[data-icon="dashboard"]::before,
|
||||
.material-symbols-outlined[data-icon="format_align_center"]::before,
|
||||
.material-symbols-outlined[data-icon="format_bold"]::before,
|
||||
.material-symbols-outlined[data-icon="format_list_bulleted"]::before,
|
||||
.material-symbols-outlined[data-icon="format_list_numbered"]::before,
|
||||
.material-symbols-outlined[data-icon="format_quote"]::before,
|
||||
.material-symbols-outlined[data-icon="function"]::before,
|
||||
.material-symbols-outlined[data-icon="horizontal_rule"]::before,
|
||||
.material-symbols-outlined[data-icon="image"]::before,
|
||||
.material-symbols-outlined[data-icon="looks_one"]::before,
|
||||
.material-symbols-outlined[data-icon="looks_two"]::before,
|
||||
.material-symbols-outlined[data-icon="looks_3"]::before,
|
||||
.material-symbols-outlined[data-icon="looks_4"]::before,
|
||||
.material-symbols-outlined[data-icon="lock"]::before,
|
||||
.material-symbols-outlined[data-icon="lock_open"]::before,
|
||||
.material-symbols-outlined[data-icon="palette"]::before,
|
||||
.material-symbols-outlined[data-icon="playlist_add"]::before,
|
||||
.material-symbols-outlined[data-icon="redo"]::before,
|
||||
.material-symbols-outlined[data-icon="remove"]::before,
|
||||
.material-symbols-outlined[data-icon="settings"]::before,
|
||||
.material-symbols-outlined[data-icon="table_chart"]::before,
|
||||
.material-symbols-outlined[data-icon="text_fields"]::before,
|
||||
.material-symbols-outlined[data-icon="toc"]::before,
|
||||
.material-symbols-outlined[data-icon="translate"]::before,
|
||||
.material-symbols-outlined[data-icon="upload_file"]::before,
|
||||
.material-symbols-outlined[data-icon="view_column"]::before {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
-webkit-mask: none;
|
||||
mask: none;
|
||||
}
|
||||
.material-symbols-outlined[data-icon="backspace"]::before { content: "⌫"; }
|
||||
.material-symbols-outlined[data-icon="check"]::before { content: "✓"; }
|
||||
.material-symbols-outlined[data-icon="checklist"]::before { content: "☑"; }
|
||||
.material-symbols-outlined[data-icon="code"]::before { content: "{}"; }
|
||||
.material-symbols-outlined[data-icon="dashboard"]::before { content: "▦"; }
|
||||
.material-symbols-outlined[data-icon="format_align_center"]::before { content: "≡"; }
|
||||
.material-symbols-outlined[data-icon="format_bold"]::before { content: "B"; }
|
||||
.material-symbols-outlined[data-icon="format_list_bulleted"]::before { content: "☷"; }
|
||||
.material-symbols-outlined[data-icon="format_list_numbered"]::before { content: "1."; }
|
||||
.material-symbols-outlined[data-icon="format_quote"]::before { content: "“"; }
|
||||
.material-symbols-outlined[data-icon="function"]::before { content: "ƒ"; }
|
||||
.material-symbols-outlined[data-icon="horizontal_rule"]::before { content: "─"; }
|
||||
.material-symbols-outlined[data-icon="image"]::before { content: "▧"; }
|
||||
.material-symbols-outlined[data-icon="looks_one"]::before { content: "1"; }
|
||||
.material-symbols-outlined[data-icon="looks_two"]::before { content: "2"; }
|
||||
.material-symbols-outlined[data-icon="looks_3"]::before { content: "3"; }
|
||||
.material-symbols-outlined[data-icon="looks_4"]::before { content: "4"; }
|
||||
.material-symbols-outlined[data-icon="lock"]::before { content: "▣"; }
|
||||
.material-symbols-outlined[data-icon="lock_open"]::before { content: "▢"; }
|
||||
.material-symbols-outlined[data-icon="palette"]::before { content: "◐"; }
|
||||
.material-symbols-outlined[data-icon="playlist_add"]::before { content: "☰+"; }
|
||||
.material-symbols-outlined[data-icon="redo"]::before { content: "↷"; }
|
||||
.material-symbols-outlined[data-icon="remove"]::before { content: "−"; }
|
||||
.material-symbols-outlined[data-icon="settings"]::before { content: "⚙"; }
|
||||
.material-symbols-outlined[data-icon="table_chart"]::before { content: "▦"; }
|
||||
.material-symbols-outlined[data-icon="text_fields"]::before { content: "T"; }
|
||||
.material-symbols-outlined[data-icon="toc"]::before { content: "☰"; }
|
||||
.material-symbols-outlined[data-icon="translate"]::before { content: "文"; }
|
||||
.material-symbols-outlined[data-icon="upload_file"]::before { content: "⇧"; }
|
||||
.material-symbols-outlined[data-icon="view_column"]::before { content: "▥"; }
|
||||
.material-symbols-outlined[data-icon="radio_button_unchecked"]::before {
|
||||
width: .78em;
|
||||
height: .78em;
|
||||
|
||||
Reference in New Issue
Block a user