python-alfresco-mcp-server 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alfresco_mcp_server/__init__.py +27 -0
- alfresco_mcp_server/config.py +104 -0
- alfresco_mcp_server/fastmcp_server.py +259 -0
- alfresco_mcp_server/prompts/__init__.py +7 -0
- alfresco_mcp_server/prompts/search_and_analyze.py +67 -0
- alfresco_mcp_server/resources/__init__.py +7 -0
- alfresco_mcp_server/resources/repository_resources.py +205 -0
- alfresco_mcp_server/tools/__init__.py +13 -0
- alfresco_mcp_server/tools/core/__init__.py +27 -0
- alfresco_mcp_server/tools/core/browse_repository.py +164 -0
- alfresco_mcp_server/tools/core/cancel_checkout.py +160 -0
- alfresco_mcp_server/tools/core/checkin_document.py +264 -0
- alfresco_mcp_server/tools/core/checkout_document.py +258 -0
- alfresco_mcp_server/tools/core/create_folder.py +105 -0
- alfresco_mcp_server/tools/core/delete_node.py +88 -0
- alfresco_mcp_server/tools/core/download_document.py +214 -0
- alfresco_mcp_server/tools/core/get_node_properties.py +195 -0
- alfresco_mcp_server/tools/core/update_node_properties.py +138 -0
- alfresco_mcp_server/tools/core/upload_document.py +244 -0
- alfresco_mcp_server/tools/search/__init__.py +15 -0
- alfresco_mcp_server/tools/search/advanced_search.py +204 -0
- alfresco_mcp_server/tools/search/cmis_search.py +180 -0
- alfresco_mcp_server/tools/search/search_by_metadata.py +183 -0
- alfresco_mcp_server/tools/search/search_content.py +186 -0
- alfresco_mcp_server/utils/__init__.py +34 -0
- alfresco_mcp_server/utils/connection.py +114 -0
- alfresco_mcp_server/utils/file_type_analysis.py +163 -0
- alfresco_mcp_server/utils/json_utils.py +135 -0
- python_alfresco_mcp_server-1.1.0.dist-info/METADATA +631 -0
- python_alfresco_mcp_server-1.1.0.dist-info/RECORD +34 -0
- python_alfresco_mcp_server-1.1.0.dist-info/WHEEL +5 -0
- python_alfresco_mcp_server-1.1.0.dist-info/entry_points.txt +2 -0
- python_alfresco_mcp_server-1.1.0.dist-info/licenses/LICENSE +201 -0
- python_alfresco_mcp_server-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Core tools module
|
|
2
|
+
|
|
3
|
+
from . import (
|
|
4
|
+
browse_repository,
|
|
5
|
+
cancel_checkout,
|
|
6
|
+
checkin_document,
|
|
7
|
+
checkout_document,
|
|
8
|
+
create_folder,
|
|
9
|
+
delete_node,
|
|
10
|
+
download_document,
|
|
11
|
+
get_node_properties,
|
|
12
|
+
update_node_properties,
|
|
13
|
+
upload_document,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"browse_repository",
|
|
18
|
+
"cancel_checkout",
|
|
19
|
+
"checkin_document",
|
|
20
|
+
"checkout_document",
|
|
21
|
+
"create_folder",
|
|
22
|
+
"delete_node",
|
|
23
|
+
"download_document",
|
|
24
|
+
"get_node_properties",
|
|
25
|
+
"update_node_properties",
|
|
26
|
+
"upload_document",
|
|
27
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browse repository tool for Alfresco MCP Server.
|
|
3
|
+
Self-contained tool for browsing Alfresco repository structure.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from fastmcp import Context
|
|
8
|
+
|
|
9
|
+
from ...utils.connection import ensure_connection
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def browse_repository_impl(
|
|
15
|
+
parent_id: str = "-my-",
|
|
16
|
+
max_items: int = 25,
|
|
17
|
+
ctx: Optional[Context] = None
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Browse the Alfresco repository structure.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
parent_id: Parent node ID to browse (default: user's personal space)
|
|
23
|
+
max_items: Maximum number of items to return (default: 25)
|
|
24
|
+
ctx: MCP context for progress reporting
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Formatted listing of repository contents
|
|
28
|
+
"""
|
|
29
|
+
# Parameter validation and extraction
|
|
30
|
+
try:
|
|
31
|
+
# Extract parameters with fallback handling
|
|
32
|
+
if hasattr(parent_id, 'value'):
|
|
33
|
+
actual_parent_id = str(parent_id.value)
|
|
34
|
+
else:
|
|
35
|
+
actual_parent_id = str(parent_id)
|
|
36
|
+
|
|
37
|
+
if hasattr(max_items, 'value'):
|
|
38
|
+
actual_max_items = int(max_items.value)
|
|
39
|
+
else:
|
|
40
|
+
actual_max_items = int(max_items)
|
|
41
|
+
|
|
42
|
+
# Clean and normalize for display (preserve Unicode characters)
|
|
43
|
+
safe_parent_id_display = str(actual_parent_id)
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error(f"Parameter extraction error: {e}")
|
|
47
|
+
return f"ERROR: Parameter error: {str(e)}"
|
|
48
|
+
|
|
49
|
+
if ctx:
|
|
50
|
+
await ctx.info(f"Browsing repository node: {safe_parent_id_display}")
|
|
51
|
+
await ctx.report_progress(0.0)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# Get all clients that ensure_connection() already created
|
|
55
|
+
master_client = await ensure_connection()
|
|
56
|
+
|
|
57
|
+
# Access the core client that was already created
|
|
58
|
+
core_client = master_client.core
|
|
59
|
+
|
|
60
|
+
# Try high-level API first, then use raw client property (NEW: cleaner access)
|
|
61
|
+
# Check if we can use high-level nodes.get_children()
|
|
62
|
+
try:
|
|
63
|
+
# Use high-level API for browsing (preferred approach)
|
|
64
|
+
children_result = core_client.nodes.get_children(actual_parent_id, max_items=actual_max_items)
|
|
65
|
+
if children_result and hasattr(children_result, 'list') and hasattr(children_result.list, 'entries'):
|
|
66
|
+
entries = children_result.list.entries
|
|
67
|
+
logger.info(f"Browse response via high-level API: {len(entries)} entries found")
|
|
68
|
+
else:
|
|
69
|
+
raise Exception("High-level API returned unexpected format")
|
|
70
|
+
except Exception as high_level_error:
|
|
71
|
+
logger.info(f"High-level API failed, using raw client: {high_level_error}")
|
|
72
|
+
# Fallback to raw client (ensure initialization)
|
|
73
|
+
if not core_client.is_initialized:
|
|
74
|
+
return safe_format_output("❌ Error: Alfresco server unavailable")
|
|
75
|
+
# Use httpx_client property directly on AlfrescoCoreClient
|
|
76
|
+
core_httpx = core_client.httpx_client
|
|
77
|
+
|
|
78
|
+
logger.info(f"Browsing repository node: {safe_parent_id_display}")
|
|
79
|
+
logger.info(f"Max items: {actual_max_items}")
|
|
80
|
+
logger.info(f"Using URL: /nodes/{actual_parent_id}/children")
|
|
81
|
+
|
|
82
|
+
if ctx:
|
|
83
|
+
await ctx.report_progress(0.3)
|
|
84
|
+
|
|
85
|
+
# If high-level API didn't work, use HTTPx fallback
|
|
86
|
+
if 'entries' not in locals():
|
|
87
|
+
if ctx:
|
|
88
|
+
await ctx.report_progress(0.5)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Use HTTPx client as fallback
|
|
92
|
+
url = f"/nodes/{actual_parent_id}/children"
|
|
93
|
+
if actual_max_items != 25:
|
|
94
|
+
url += f"?maxItems={actual_max_items}"
|
|
95
|
+
|
|
96
|
+
response = core_httpx.get(url)
|
|
97
|
+
|
|
98
|
+
if response.status_code == 200:
|
|
99
|
+
result_data = response.json()
|
|
100
|
+
entries = result_data.get("list", {}).get("entries", [])
|
|
101
|
+
logger.info(f"Browse response via HTTPx fallback: {len(entries)} entries found")
|
|
102
|
+
|
|
103
|
+
else:
|
|
104
|
+
error_text = response.text if hasattr(response, 'text') else str(response)
|
|
105
|
+
raise Exception(f"Browse failed with status {response.status_code}: {error_text}")
|
|
106
|
+
|
|
107
|
+
except Exception as browse_error:
|
|
108
|
+
raise Exception(f"Repository browse operation failed: {str(browse_error)}")
|
|
109
|
+
|
|
110
|
+
# Check if we have entries
|
|
111
|
+
if not entries:
|
|
112
|
+
return f"Repository Browse Results\n\nNode: {safe_parent_id_display}\n\nNo child items found in this location."
|
|
113
|
+
|
|
114
|
+
if ctx:
|
|
115
|
+
await ctx.report_progress(1.0)
|
|
116
|
+
|
|
117
|
+
# Process final results
|
|
118
|
+
if entries:
|
|
119
|
+
logger.info(f"Found {len(entries)} repository items")
|
|
120
|
+
|
|
121
|
+
# Clean JSON-friendly formatting (no markdown syntax)
|
|
122
|
+
result_text = f"Repository Browse Results\n\nNode: {safe_parent_id_display}\n\n"
|
|
123
|
+
result_text += f"Parent Node: {safe_parent_id_display}\n"
|
|
124
|
+
result_text += f"Found {len(entries)} item(s):\n\n"
|
|
125
|
+
|
|
126
|
+
for i, entry_wrapper in enumerate(entries, 1):
|
|
127
|
+
# Handle JSON response structure correctly
|
|
128
|
+
if isinstance(entry_wrapper, dict) and 'entry' in entry_wrapper:
|
|
129
|
+
entry = entry_wrapper['entry']
|
|
130
|
+
else:
|
|
131
|
+
entry = entry_wrapper
|
|
132
|
+
|
|
133
|
+
# Extract values from dictionary
|
|
134
|
+
name = str(entry.get('name', 'Unknown'))
|
|
135
|
+
node_id = str(entry.get('id', 'Unknown'))
|
|
136
|
+
node_type = str(entry.get('nodeType', 'Unknown'))
|
|
137
|
+
is_folder = entry.get('isFolder', False)
|
|
138
|
+
created_at = str(entry.get('createdAt', 'Unknown'))
|
|
139
|
+
|
|
140
|
+
# Choose icon based on type
|
|
141
|
+
icon = "[FOLDER]" if is_folder else "[FILE]"
|
|
142
|
+
|
|
143
|
+
result_text += f"{i}. {icon} {name}\n"
|
|
144
|
+
result_text += f" - ID: {node_id}\n"
|
|
145
|
+
result_text += f" - Type: {node_type}\n"
|
|
146
|
+
result_text += f" - Created: {created_at}\n\n"
|
|
147
|
+
|
|
148
|
+
result_text += f"Navigation help:\n"
|
|
149
|
+
result_text += "• Use the node ID to browse deeper: browse_repository(parent_id=\"<node_id>\")\n"
|
|
150
|
+
result_text += "• Common parent IDs: -root- (repository root), -shared- (shared folder), -my- (my files)\n"
|
|
151
|
+
|
|
152
|
+
return result_text
|
|
153
|
+
else:
|
|
154
|
+
return f"Repository Browse Results\n\nNode: {safe_parent_id_display}\n\nNo child items found in this location."
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
# Preserve Unicode characters in error messages
|
|
158
|
+
error_msg = f"ERROR: Repository browse failed: {str(e)}"
|
|
159
|
+
if ctx:
|
|
160
|
+
await ctx.error(error_msg)
|
|
161
|
+
return error_msg
|
|
162
|
+
|
|
163
|
+
if ctx:
|
|
164
|
+
await ctx.info("Repository browse completed!")
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cancel checkout tool implementation for Alfresco MCP Server.
|
|
3
|
+
Handles canceling document checkout with cleanup and unlock management.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import pathlib
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from fastmcp import Context
|
|
10
|
+
|
|
11
|
+
from ...utils.connection import get_core_client
|
|
12
|
+
from ...utils.json_utils import safe_format_output
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def cancel_checkout_impl(
|
|
18
|
+
node_id: str,
|
|
19
|
+
ctx: Context = None
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Cancel checkout of a document, discarding any working copy.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
node_id: Original node ID that was checked out
|
|
25
|
+
ctx: MCP context for progress reporting
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Cancellation confirmation and cleanup status
|
|
29
|
+
"""
|
|
30
|
+
if ctx:
|
|
31
|
+
await ctx.info(f"Cancelling checkout for: {node_id}")
|
|
32
|
+
await ctx.report_progress(0.1)
|
|
33
|
+
|
|
34
|
+
if not node_id.strip():
|
|
35
|
+
return safe_format_output("❌ Error: node_id is required")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
logger.info(f"Starting cancel checkout: node {node_id}")
|
|
39
|
+
core_client = await get_core_client()
|
|
40
|
+
|
|
41
|
+
# Clean the node ID
|
|
42
|
+
clean_node_id = node_id.strip()
|
|
43
|
+
if clean_node_id.startswith('alfresco://'):
|
|
44
|
+
clean_node_id = clean_node_id.split('/')[-1]
|
|
45
|
+
|
|
46
|
+
if ctx:
|
|
47
|
+
await ctx.info("Checking node status...")
|
|
48
|
+
await ctx.report_progress(0.3)
|
|
49
|
+
|
|
50
|
+
# Get node information to validate using high-level core client
|
|
51
|
+
node_response = core_client.nodes.get(node_id=clean_node_id)
|
|
52
|
+
|
|
53
|
+
if not hasattr(node_response, 'entry'):
|
|
54
|
+
return f"ERROR: Failed to get node information for: {clean_node_id}"
|
|
55
|
+
|
|
56
|
+
node_info = node_response.entry
|
|
57
|
+
filename = getattr(node_info, 'name', f"document_{clean_node_id}")
|
|
58
|
+
|
|
59
|
+
if ctx:
|
|
60
|
+
await ctx.info(">> Performing Alfresco unlock using high-level client...")
|
|
61
|
+
await ctx.report_progress(0.5)
|
|
62
|
+
|
|
63
|
+
# Use high-level core client unlock method
|
|
64
|
+
try:
|
|
65
|
+
logger.info(f"Attempting to unlock document: {clean_node_id}")
|
|
66
|
+
unlock_response = core_client.versions.cancel_checkout(node_id=clean_node_id)
|
|
67
|
+
if unlock_response and hasattr(unlock_response, 'entry'):
|
|
68
|
+
api_status = "✅ Document unlocked in Alfresco"
|
|
69
|
+
else:
|
|
70
|
+
api_status = "✅ Document unlocked in Alfresco"
|
|
71
|
+
logger.info(f"Document unlocked successfully: {clean_node_id}")
|
|
72
|
+
except Exception as unlock_error:
|
|
73
|
+
error_str = str(unlock_error)
|
|
74
|
+
if "404" in error_str:
|
|
75
|
+
# Document might not be locked
|
|
76
|
+
api_status = "ℹ️ Document was not locked in Alfresco"
|
|
77
|
+
logger.info(f"Document was not locked: {clean_node_id}")
|
|
78
|
+
elif "405" in error_str:
|
|
79
|
+
# Server doesn't support lock/unlock APIs
|
|
80
|
+
api_status = "WARNING: Server doesn't support lock/unlock APIs (treating as unlocked)"
|
|
81
|
+
logger.warning(f"Server doesn't support unlock API for {clean_node_id}")
|
|
82
|
+
else:
|
|
83
|
+
api_status = f"WARNING: Alfresco unlock failed: {error_str}"
|
|
84
|
+
logger.error(f"Failed to unlock document {clean_node_id}: {error_str}")
|
|
85
|
+
|
|
86
|
+
if ctx:
|
|
87
|
+
await ctx.info("Cleaning up local files...")
|
|
88
|
+
await ctx.report_progress(0.7)
|
|
89
|
+
|
|
90
|
+
# Clean up local checkout tracking
|
|
91
|
+
downloads_dir = pathlib.Path.home() / "Downloads"
|
|
92
|
+
checkout_dir = downloads_dir / "checkout"
|
|
93
|
+
checkout_manifest_path = checkout_dir / ".checkout_manifest.json"
|
|
94
|
+
|
|
95
|
+
checkout_data = {}
|
|
96
|
+
cleanup_status = [api_status]
|
|
97
|
+
|
|
98
|
+
if checkout_manifest_path.exists():
|
|
99
|
+
try:
|
|
100
|
+
with open(checkout_manifest_path, 'r') as f:
|
|
101
|
+
checkout_data = json.load(f)
|
|
102
|
+
except:
|
|
103
|
+
checkout_data = {}
|
|
104
|
+
|
|
105
|
+
# Check if this node is tracked in local checkouts
|
|
106
|
+
if 'checkouts' in checkout_data and clean_node_id in checkout_data['checkouts']:
|
|
107
|
+
checkout_info = checkout_data['checkouts'][clean_node_id]
|
|
108
|
+
checkout_filename = checkout_info['local_file']
|
|
109
|
+
checkout_file_path = checkout_dir / checkout_filename
|
|
110
|
+
|
|
111
|
+
# Remove local checkout file
|
|
112
|
+
try:
|
|
113
|
+
if checkout_file_path.exists():
|
|
114
|
+
checkout_file_path.unlink()
|
|
115
|
+
cleanup_status.append("🗑️ Local checkout file removed")
|
|
116
|
+
logger.info(f"Removed local checkout file: {checkout_file_path}")
|
|
117
|
+
else:
|
|
118
|
+
cleanup_status.append("ℹ️ Local checkout file already removed")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
cleanup_status.append(f"WARNING: Could not remove local file: {e}")
|
|
121
|
+
logger.warning(f"Failed to remove local file {checkout_file_path}: {e}")
|
|
122
|
+
|
|
123
|
+
# Remove from tracking
|
|
124
|
+
del checkout_data['checkouts'][clean_node_id]
|
|
125
|
+
|
|
126
|
+
# Update manifest
|
|
127
|
+
try:
|
|
128
|
+
with open(checkout_manifest_path, 'w') as f:
|
|
129
|
+
json.dump(checkout_data, f, indent=2)
|
|
130
|
+
cleanup_status.append(">> Checkout tracking updated")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
cleanup_status.append(f"WARNING: Could not update tracking: {e}")
|
|
133
|
+
else:
|
|
134
|
+
cleanup_status.append("ℹ️ No local checkout tracking found")
|
|
135
|
+
|
|
136
|
+
if ctx:
|
|
137
|
+
await ctx.info("Document unlocked!")
|
|
138
|
+
await ctx.report_progress(1.0)
|
|
139
|
+
|
|
140
|
+
# Clean JSON-friendly formatting (no markdown syntax)
|
|
141
|
+
result = f"🔓 Document Unlocked\n\n"
|
|
142
|
+
result += f">> Document: {filename}\n"
|
|
143
|
+
result += f"ID: Node ID: {clean_node_id}\n"
|
|
144
|
+
result += f"🕒 Unlocked: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
|
145
|
+
result += f"🧹 Cleanup Status:\n"
|
|
146
|
+
|
|
147
|
+
for status in cleanup_status:
|
|
148
|
+
result += f" {status}\n"
|
|
149
|
+
|
|
150
|
+
result += f"\nINFO: Note: Document is now available for others to edit."
|
|
151
|
+
result += f"\nWARNING: Important: Any unsaved changes in the local file have been discarded."
|
|
152
|
+
|
|
153
|
+
return safe_format_output(result)
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
error_msg = f"❌ Cancel checkout failed: {str(e)}"
|
|
157
|
+
if ctx:
|
|
158
|
+
await ctx.error(error_msg)
|
|
159
|
+
logger.error(f"Cancel checkout failed: {e}")
|
|
160
|
+
return safe_format_output(error_msg)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checkin document tool implementation for Alfresco MCP Server.
|
|
3
|
+
Handles document checkin with versioning and cleanup management.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import json
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
|
|
14
|
+
from ...utils.connection import get_core_client
|
|
15
|
+
from ...config import config
|
|
16
|
+
from ...utils.json_utils import safe_format_output
|
|
17
|
+
from python_alfresco_api.raw_clients.alfresco_core_client.core_client.types import File
|
|
18
|
+
from python_alfresco_api.raw_clients.alfresco_core_client.core_client.api.nodes.update_node_content import sync as update_node_content_sync
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def checkin_document_impl(
|
|
24
|
+
node_id: str,
|
|
25
|
+
comment: str = "",
|
|
26
|
+
major_version: bool = False,
|
|
27
|
+
file_path: str = "",
|
|
28
|
+
new_name: str = "",
|
|
29
|
+
ctx: Context = None
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Check in a document after editing using Alfresco REST API.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
node_id: Original node ID to check in (not working copy)
|
|
35
|
+
comment: Check-in comment (default: empty)
|
|
36
|
+
major_version: Whether to create a major version (default: False = minor version)
|
|
37
|
+
file_path: Specific file path to upload (if empty, auto-detects from checkout folder)
|
|
38
|
+
new_name: Optional new name for the file during checkin (default: keep original name)
|
|
39
|
+
ctx: MCP context for progress reporting
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Check-in confirmation with version details and cleanup status
|
|
43
|
+
"""
|
|
44
|
+
if ctx:
|
|
45
|
+
await ctx.info(f"Checking in document: {node_id}")
|
|
46
|
+
await ctx.info("Validating parameters...")
|
|
47
|
+
await ctx.report_progress(0.1)
|
|
48
|
+
|
|
49
|
+
if not node_id.strip():
|
|
50
|
+
return safe_format_output("❌ Error: node_id is required")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
logger.info(f"Starting checkin: node {node_id}")
|
|
54
|
+
core_client = await get_core_client()
|
|
55
|
+
|
|
56
|
+
# Clean the node ID
|
|
57
|
+
clean_node_id = node_id.strip()
|
|
58
|
+
if clean_node_id.startswith('alfresco://'):
|
|
59
|
+
clean_node_id = clean_node_id.split('/')[-1]
|
|
60
|
+
|
|
61
|
+
if ctx:
|
|
62
|
+
await ctx.info("Finding checkout file...")
|
|
63
|
+
await ctx.report_progress(0.2)
|
|
64
|
+
|
|
65
|
+
# Find the file to upload
|
|
66
|
+
checkout_file_path = None
|
|
67
|
+
checkout_data = {}
|
|
68
|
+
working_copy_id = None
|
|
69
|
+
|
|
70
|
+
if file_path:
|
|
71
|
+
# Use specific file path provided - handle quotes and path expansion
|
|
72
|
+
cleaned_file_path = file_path.strip().strip('"').strip("'")
|
|
73
|
+
|
|
74
|
+
# Handle macOS/Unix path expansion (~/Documents, etc.)
|
|
75
|
+
if cleaned_file_path.startswith('~'):
|
|
76
|
+
cleaned_file_path = os.path.expanduser(cleaned_file_path)
|
|
77
|
+
|
|
78
|
+
checkout_file_path = pathlib.Path(cleaned_file_path)
|
|
79
|
+
if not checkout_file_path.exists():
|
|
80
|
+
return safe_format_output(f"❌ Specified file not found: {cleaned_file_path} (cleaned from: {file_path})")
|
|
81
|
+
|
|
82
|
+
# Linux-specific: Check file permissions
|
|
83
|
+
if not os.access(checkout_file_path, os.R_OK):
|
|
84
|
+
return safe_format_output(f"❌ File not readable (permission denied): {cleaned_file_path}")
|
|
85
|
+
else:
|
|
86
|
+
# Auto-detect from checkout folder
|
|
87
|
+
downloads_dir = pathlib.Path.home() / "Downloads"
|
|
88
|
+
checkout_dir = downloads_dir / "checkout"
|
|
89
|
+
checkout_manifest_path = checkout_dir / ".checkout_manifest.json"
|
|
90
|
+
|
|
91
|
+
if checkout_manifest_path.exists():
|
|
92
|
+
try:
|
|
93
|
+
with open(checkout_manifest_path, 'r') as f:
|
|
94
|
+
checkout_data = json.load(f)
|
|
95
|
+
except:
|
|
96
|
+
checkout_data = {}
|
|
97
|
+
|
|
98
|
+
if 'checkouts' in checkout_data and clean_node_id in checkout_data['checkouts']:
|
|
99
|
+
checkout_info = checkout_data['checkouts'][clean_node_id]
|
|
100
|
+
checkout_filename = checkout_info['local_file']
|
|
101
|
+
locked_node_id = checkout_info.get('locked_node_id', clean_node_id) # Updated for lock API
|
|
102
|
+
checkout_file_path = checkout_dir / checkout_filename
|
|
103
|
+
|
|
104
|
+
if not checkout_file_path.exists():
|
|
105
|
+
return safe_format_output(f"❌ Checkout file not found: {checkout_file_path}. File may have been moved or deleted.")
|
|
106
|
+
else:
|
|
107
|
+
return safe_format_output(f"❌ No locked document found for node {clean_node_id}. Use checkout_document first, or specify file_path manually.")
|
|
108
|
+
|
|
109
|
+
if ctx:
|
|
110
|
+
await ctx.info(f"Uploading file: {checkout_file_path.name}")
|
|
111
|
+
await ctx.report_progress(0.4)
|
|
112
|
+
|
|
113
|
+
# Read the file content
|
|
114
|
+
with open(checkout_file_path, 'rb') as f:
|
|
115
|
+
file_content = f.read()
|
|
116
|
+
|
|
117
|
+
logger.info(f"Checkin file: {checkout_file_path.name} ({len(file_content)} bytes)")
|
|
118
|
+
# Get original node info using high-level core client
|
|
119
|
+
node_response = core_client.nodes.get(node_id=clean_node_id)
|
|
120
|
+
if not hasattr(node_response, 'entry'):
|
|
121
|
+
return safe_format_output(f"❌ Failed to get node information for: {clean_node_id}")
|
|
122
|
+
|
|
123
|
+
node_info = node_response.entry
|
|
124
|
+
original_filename = getattr(node_info, 'name', f"document_{clean_node_id}")
|
|
125
|
+
|
|
126
|
+
if ctx:
|
|
127
|
+
await ctx.info("Uploading new content with versioning using high-level API...")
|
|
128
|
+
await ctx.report_progress(0.7)
|
|
129
|
+
|
|
130
|
+
# **USE HIGH-LEVEL API: update_node_content.sync()**
|
|
131
|
+
# Use new name if provided, otherwise keep original filename
|
|
132
|
+
final_filename = new_name.strip() if new_name.strip() else original_filename
|
|
133
|
+
|
|
134
|
+
# Create File object with content
|
|
135
|
+
file_obj = File(
|
|
136
|
+
payload=BytesIO(file_content),
|
|
137
|
+
file_name=final_filename,
|
|
138
|
+
mime_type="application/octet-stream"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Use high-level update_node_content API instead of manual httpx
|
|
142
|
+
try:
|
|
143
|
+
version_type = "major" if major_version else "minor"
|
|
144
|
+
logger.info(f"Updating content for {clean_node_id} ({version_type} version)")
|
|
145
|
+
# Ensure raw client is initialized before using it
|
|
146
|
+
if not core_client.is_initialized:
|
|
147
|
+
return safe_format_output("❌ Error: Alfresco server unavailable")
|
|
148
|
+
# Use high-level update_node_content API
|
|
149
|
+
content_response = update_node_content_sync(
|
|
150
|
+
node_id=clean_node_id,
|
|
151
|
+
client=core_client.raw_client,
|
|
152
|
+
body=file_obj,
|
|
153
|
+
major_version=major_version,
|
|
154
|
+
comment=comment if comment else None,
|
|
155
|
+
name=new_name.strip() if new_name.strip() else None
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if not content_response:
|
|
159
|
+
return safe_format_output(f"❌ Failed to update document content using high-level API")
|
|
160
|
+
|
|
161
|
+
logger.info(f"Content updated successfully for {clean_node_id}")
|
|
162
|
+
|
|
163
|
+
# CRITICAL: Unlock the document after successful content update to complete checkin
|
|
164
|
+
try:
|
|
165
|
+
logger.info(f"Unlocking document after successful checkin: {clean_node_id}")
|
|
166
|
+
unlock_response = core_client.versions.cancel_checkout(node_id=clean_node_id)
|
|
167
|
+
logger.info(f"Document unlocked successfully after checkin: {clean_node_id}")
|
|
168
|
+
except Exception as unlock_error:
|
|
169
|
+
error_str = str(unlock_error)
|
|
170
|
+
if "404" in error_str:
|
|
171
|
+
logger.info(f"Document was not locked (already unlocked): {clean_node_id}")
|
|
172
|
+
elif "405" in error_str:
|
|
173
|
+
logger.warning(f"Server doesn't support unlock APIs: {clean_node_id}")
|
|
174
|
+
else:
|
|
175
|
+
logger.error(f"Failed to unlock document after checkin: {clean_node_id} - {error_str}")
|
|
176
|
+
# Don't fail the entire checkin if unlock fails - content was updated successfully
|
|
177
|
+
|
|
178
|
+
except Exception as api_error:
|
|
179
|
+
return safe_format_output(f"❌ Failed to update document content: {str(api_error)}")
|
|
180
|
+
|
|
181
|
+
# Get updated node info to show version details using high-level core client
|
|
182
|
+
updated_node_response = core_client.nodes.get(node_id=clean_node_id)
|
|
183
|
+
updated_node = updated_node_response.entry if hasattr(updated_node_response, 'entry') else {}
|
|
184
|
+
|
|
185
|
+
# Extract version using multiple access methods (same as get_node_properties)
|
|
186
|
+
new_version = 'Unknown'
|
|
187
|
+
if hasattr(updated_node, 'properties') and updated_node.properties:
|
|
188
|
+
try:
|
|
189
|
+
# Try to_dict() method first
|
|
190
|
+
if hasattr(updated_node.properties, 'to_dict'):
|
|
191
|
+
props_dict = updated_node.properties.to_dict()
|
|
192
|
+
new_version = props_dict.get('cm:versionLabel', 'Unknown')
|
|
193
|
+
logger.info(f"Version found via to_dict(): {new_version}")
|
|
194
|
+
# Try direct attribute access
|
|
195
|
+
elif hasattr(updated_node.properties, 'cm_version_label') or hasattr(updated_node.properties, 'cm:versionLabel'):
|
|
196
|
+
new_version = getattr(updated_node.properties, 'cm_version_label', getattr(updated_node.properties, 'cm:versionLabel', 'Unknown'))
|
|
197
|
+
logger.info(f"Version found via attributes: {new_version}")
|
|
198
|
+
# Try dict-like access
|
|
199
|
+
elif hasattr(updated_node.properties, '__getitem__'):
|
|
200
|
+
new_version = updated_node.properties.get('cm:versionLabel', 'Unknown') if hasattr(updated_node.properties, 'get') else updated_node.properties['cm:versionLabel'] if 'cm:versionLabel' in updated_node.properties else 'Unknown'
|
|
201
|
+
logger.info(f"Version found via dict access: {new_version}")
|
|
202
|
+
else:
|
|
203
|
+
logger.warning(f"Version properties - type: {type(updated_node.properties)}, methods: {dir(updated_node.properties)}")
|
|
204
|
+
except Exception as version_error:
|
|
205
|
+
logger.error(f"Error extracting version: {version_error}")
|
|
206
|
+
new_version = 'Unknown'
|
|
207
|
+
else:
|
|
208
|
+
logger.warning("No properties found for version extraction")
|
|
209
|
+
|
|
210
|
+
if ctx:
|
|
211
|
+
await ctx.info("Cleaning up checkout tracking...")
|
|
212
|
+
await ctx.report_progress(0.9)
|
|
213
|
+
|
|
214
|
+
# Clean up checkout tracking
|
|
215
|
+
cleanup_status = "ℹ️ No checkout tracking to clean up"
|
|
216
|
+
if checkout_data and 'checkouts' in checkout_data and clean_node_id in checkout_data['checkouts']:
|
|
217
|
+
del checkout_data['checkouts'][clean_node_id]
|
|
218
|
+
|
|
219
|
+
checkout_manifest_path = pathlib.Path.home() / "Downloads" / "checkout" / ".checkout_manifest.json"
|
|
220
|
+
with open(checkout_manifest_path, 'w') as f:
|
|
221
|
+
json.dump(checkout_data, f, indent=2)
|
|
222
|
+
|
|
223
|
+
# Optionally remove the checkout file
|
|
224
|
+
try:
|
|
225
|
+
checkout_file_path.unlink()
|
|
226
|
+
cleanup_status = "🗑️ Local checkout file cleaned up"
|
|
227
|
+
except:
|
|
228
|
+
cleanup_status = "WARNING: Local checkout file cleanup failed"
|
|
229
|
+
|
|
230
|
+
if ctx:
|
|
231
|
+
await ctx.info("Checkin completed: Content updated + Document unlocked + Version created!")
|
|
232
|
+
await ctx.report_progress(1.0)
|
|
233
|
+
|
|
234
|
+
# Format file size
|
|
235
|
+
file_size = len(file_content)
|
|
236
|
+
if file_size < 1024:
|
|
237
|
+
size_str = f"{file_size} bytes"
|
|
238
|
+
elif file_size < 1024 * 1024:
|
|
239
|
+
size_str = f"{file_size / 1024:.1f} KB"
|
|
240
|
+
else:
|
|
241
|
+
size_str = f"{file_size / (1024 * 1024):.1f} MB"
|
|
242
|
+
|
|
243
|
+
# Clean JSON-friendly formatting (no markdown syntax)
|
|
244
|
+
return safe_format_output(f"""✅ Document checked in successfully!
|
|
245
|
+
|
|
246
|
+
📄 Document: {final_filename}
|
|
247
|
+
🔢 New Version: {new_version} ({version_type})
|
|
248
|
+
📝 Comment: {comment if comment else '(no comment)'}
|
|
249
|
+
📊 File Size: {size_str}
|
|
250
|
+
🔗 Node ID: {clean_node_id}
|
|
251
|
+
{f"📝 Renamed: {original_filename} → {final_filename}" if new_name.strip() else ""}
|
|
252
|
+
|
|
253
|
+
{cleanup_status}
|
|
254
|
+
|
|
255
|
+
Next Steps:
|
|
256
|
+
🔓 Document is now UNLOCKED and available for others to edit
|
|
257
|
+
✅ New version has been created with your changes
|
|
258
|
+
✅ You can continue editing by using checkout_document again
|
|
259
|
+
|
|
260
|
+
Status: Content updated → Document unlocked → Checkin complete!""")
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.error(f"Checkin failed: {str(e)}")
|
|
264
|
+
return safe_format_output(f"❌ Checkin failed: {str(e)}")
|