Skip to content

Commit 5d419c2

Browse files
author
JaJa
committed
refactor: Tool 操作名稱簡化統一、Handler 重命名對齊、Session 使用範圍保護、CI/CD 版本管理集中化、測試腳本增強
1. Tool 操作名稱簡化統一 - 所有模組 Tool 操作名稱從冗長動詞改為簡潔慣用命名 - Email:load → get、detect_format → detect - Excel:get_workbook_properties → get、set_workbook_properties → set、edit_sheet_properties → set_sheet、get_sheet_properties → get_sheet、get_sheet_info → sheet_info、export_range_image → export、get_page_breaks → list - PDF:get_details → details、get_info → info、get_stamps → list - PowerPoint:get_used → list、get_layouts → list、get_masters → list - Word:insert_field → add、get_fields → list、get_field_detail → get、edit_field → edit、delete_field → delete、add_form_field → add_form、edit_form_field → edit_form、delete_form_field → delete_form、get_form_fields → list_forms、get_footnotes → list、get_endnotes → list、insert_blank_page → add、get_revisions → list、get_textboxes → list、get_styles → list - 通用模式:get_xxx → get/list、insert_xxx → add、edit_xxx → edit/set - 所有 Tool XML 文件註解、Description 屬性同步更新 - docs/tools.html 文檔同步更新 2. Handler 類別重命名對齊 - LoadEmailCalendarHandler → GetEmailCalendarHandler - LoadEmailContactHandler → GetEmailContactHandler - LoadEmailFileHandler → GetEmailFileHandler - ExportRangeImageExcelHandler → ExportImageExcelHandler - GetExcelPageBreaksHandler → ListExcelPageBreaksHandler - EditSheetPropertiesHandler → SetSheetPropertiesHandler - GetPdfStampsHandler → ListPdfStampsHandler - GetUsedPptFontsHandler → ListPptFontsHandler - GetLayoutsHandler → ListLayoutsHandler - GetMastersHandler → ListMastersHandler - InsertFieldWordHandler → AddFieldWordHandler - GetFieldsWordHandler → ListFieldsWordHandler - GetFormFieldsWordHandler → ListFormFieldsWordHandler - GetWordEndnotesHandler → ListWordEndnotesHandler - GetWordFootnotesHandler → ListWordFootnotesHandler - InsertBlankPageWordHandler → AddBlankPageWordHandler - GetRevisionsHandler → ListRevisionsHandler - GetTextboxesWordHandler → ListTextboxesWordHandler - GetWordStylesHandler → ListWordStylesHandler - 對應測試類別同步重命名(19 組 Handler + Tests) 3. Session 使用範圍保護機制 - DocumentSession.cs:新增 _activeUsers 計數器追蹤活躍使用者數量 - DocumentSession.cs:新增 AcquireUsage() 方法回傳 IDisposable 使用範圍 - DocumentSession.cs:新增 HasActiveUsers 屬性判斷是否有進行中操作 - DocumentSession.cs:新增 UsageScope 內部類別管理使用者計數生命週期 - DocumentSessionManager.cs:閒置清理邏輯加入 HasActiveUsers 檢查,防止操作進行中被清除 - DocumentContext.cs:持有 UsageScope 並在 Dispose 時釋放 4. CI/CD 版本管理集中化 - build-multi-platform.yml:新增 prepare Job 獨立計算版本號 - build-multi-platform.yml:build 與 create-release Job 改用 needs.prepare.outputs 取得版本資訊 - 消除重複版本計算邏輯,提升可維護性 5. 程式碼品質改善 - McpServerBuilderExtensions.cs:靜態方法呼叫改為擴充方法鏈式呼叫 - FontHelper.cs:底線映射擴充支援布林字串("true"/"false") - WordListHelper.cs:操作名稱訊息對齊新命名規範 6. 測試腳本增強 - test.ps1:新增 -LogFile 參數支援匯出失敗測試詳細資訊 - test.ps1:解析 TRX 測試結果 XML 提取測試名稱、錯誤訊息與堆疊追蹤
1 parent 2e2d594 commit 5d419c2

File tree

308 files changed

+1574
-1449
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

308 files changed

+1574
-1449
lines changed

.github/workflows/build-multi-platform.yml

Lines changed: 40 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,42 @@ on:
3030
workflow_dispatch:
3131

3232
jobs:
33+
prepare:
34+
name: Calculate Next Version
35+
runs-on: ubuntu-latest
36+
outputs:
37+
version: ${{ steps.next_version.outputs.version }}
38+
tag: ${{ steps.next_version.outputs.tag }}
39+
steps:
40+
- name: Checkout code
41+
uses: actions/checkout@v4
42+
with:
43+
fetch-depth: 0
44+
45+
- name: Calculate next version
46+
id: next_version
47+
shell: bash
48+
run: |
49+
git fetch --prune --tags --force
50+
latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v1.0.0")
51+
version=${latest_tag#v}
52+
IFS='.' read -r major minor patch <<< "$version"
53+
# Increment patch version until finding one that doesn't exist
54+
while true; do
55+
patch=$((patch + 1))
56+
candidate="v${major}.${minor}.${patch}"
57+
if ! git rev-parse "$candidate" >/dev/null 2>&1; then
58+
break
59+
fi
60+
echo "Version $candidate already exists, trying next..."
61+
done
62+
echo "version=${major}.${minor}.${patch}" >> $GITHUB_OUTPUT
63+
echo "tag=v${major}.${minor}.${patch}" >> $GITHUB_OUTPUT
64+
echo "Next version: ${major}.${minor}.${patch} (tag: v${major}.${minor}.${patch})"
65+
3366
build:
3467
name: Build ${{ matrix.platform }}
68+
needs: prepare
3569
runs-on: ${{ matrix.os }}
3670

3771
strategy:
@@ -59,15 +93,6 @@ jobs:
5993
with:
6094
fetch-depth: 0
6195

62-
- name: Get version from Git tag
63-
id: get_version
64-
shell: bash
65-
run: |
66-
latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v1.0.0")
67-
version=${latest_tag#v}
68-
echo "version=$version" >> $GITHUB_OUTPUT
69-
echo "Detected version: $version"
70-
7196
- name: Setup .NET
7297
uses: actions/setup-dotnet@v4
7398
with:
@@ -111,7 +136,7 @@ jobs:
111136
- name: Build and publish
112137
run: ${{ matrix.script }}
113138
env:
114-
VERSION: ${{ steps.get_version.outputs.version }}
139+
VERSION: ${{ needs.prepare.outputs.version }}
115140

116141
- name: Clean up license files from build output (Unix)
117142
if: runner.os != 'Windows'
@@ -156,7 +181,7 @@ jobs:
156181

157182
create-release:
158183
name: Create Release
159-
needs: build
184+
needs: [prepare, build]
160185
runs-on: ubuntu-latest
161186
# Security: Only create releases for pushes to main/master branches, not for PRs
162187
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
@@ -174,40 +199,7 @@ jobs:
174199
uses: actions/checkout@v4
175200
with:
176201
fetch-depth: 0
177-
178-
- name: Get latest version tag
179-
id: get_version
180-
shell: bash
181-
run: |
182-
# 確保獲取最新的 tag(包括遠程)
183-
git fetch --prune --tags --force
184-
latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v1.0.0")
185-
echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT
186-
echo "Latest tag: $latest_tag"
187-
188-
- name: Calculate next version
189-
id: next_version
190-
shell: bash
191-
run: |
192-
latest_tag="${{ steps.get_version.outputs.latest_tag }}"
193-
# 移除 v 前綴
194-
version=${latest_tag#v}
195-
# 分割版本號
196-
IFS='.' read -r major minor patch <<< "$version"
197-
# 遞增補丁版本號,直到找到不存在的版本號
198-
while true; do
199-
patch=$((patch + 1))
200-
candidate_version="v${major}.${minor}.${patch}"
201-
# 檢查 tag 是否已存在
202-
if ! git rev-parse "$candidate_version" >/dev/null 2>&1; then
203-
next_version="$candidate_version"
204-
break
205-
fi
206-
echo "Version $candidate_version already exists, trying next..."
207-
done
208-
echo "next_version=$next_version" >> $GITHUB_OUTPUT
209-
echo "Next version: $next_version"
210-
202+
211203
- name: Download all artifacts
212204
uses: actions/download-artifact@v4
213205
with:
@@ -248,7 +240,7 @@ jobs:
248240
env:
249241
GH_TOKEN: ${{ github.token }}
250242
run: |
251-
tag_name="${{ steps.next_version.outputs.next_version }}"
243+
tag_name="${{ needs.prepare.outputs.tag }}"
252244
release_notes=$(cat <<'EOF'
253245
Automated build from commit ${{ github.sha }}
254246
@@ -322,7 +314,7 @@ jobs:
322314
exit 0
323315
fi
324316
325-
tag_name="${{ steps.next_version.outputs.next_version }}"
317+
tag_name="${{ needs.prepare.outputs.tag }}"
326318
version="${tag_name#v}"
327319
328320
# Calculate sha256 for macOS archives
@@ -376,7 +368,7 @@ jobs:
376368
fi
377369
378370
outputs:
379-
release_tag: ${{ steps.next_version.outputs.next_version }}
371+
release_tag: ${{ needs.prepare.outputs.tag }}
380372

381373
docker-build-and-push:
382374
name: Build and Push Docker Image

Core/McpServerBuilderExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public static IMcpServerBuilder WithFilteredToolsAndSchemas(
103103
ServerConfig serverConfig,
104104
SessionConfig sessionConfig)
105105
{
106-
return WithFilteredToolsAndSchemas(builder, serverConfig, sessionConfig, new ExtensionConfig());
106+
return builder.WithFilteredToolsAndSchemas(serverConfig, sessionConfig, new ExtensionConfig());
107107
}
108108

109109
/// <summary>

Core/Session/DocumentContext.cs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ public sealed class DocumentContext<T> : IDisposable
3333
/// </summary>
3434
private readonly DocumentSessionManager? _sessionManager;
3535

36+
/// <summary>
37+
/// Usage scope that prevents the session from being cleaned up during tool execution
38+
/// </summary>
39+
private readonly IDisposable? _usageScope;
40+
3641
/// <summary>
3742
/// Tracks whether this context has been disposed
3843
/// </summary>
@@ -47,15 +52,17 @@ public sealed class DocumentContext<T> : IDisposable
4752
/// <param name="path">Source file path (null for session mode)</param>
4853
/// <param name="ownsDocument">Whether this context owns and should dispose the document</param>
4954
/// <param name="identity">The requestor identity for session isolation</param>
55+
/// <param name="usageScope">Usage scope to hold during the lifetime of this context (null for file mode)</param>
5056
private DocumentContext(T document, DocumentSessionManager? sessionManager, string? sessionId, string? path,
51-
bool ownsDocument, SessionIdentity identity)
57+
bool ownsDocument, SessionIdentity identity, IDisposable? usageScope = null)
5258
{
5359
Document = document;
5460
_sessionManager = sessionManager;
5561
_sessionId = sessionId;
5662
SourcePath = path;
5763
_ownsDocument = ownsDocument;
5864
_identity = identity;
65+
_usageScope = usageScope;
5966
}
6067

6168
/// <summary>
@@ -87,6 +94,7 @@ public void Dispose()
8794
if (_disposed) return;
8895
_disposed = true;
8996

97+
_usageScope?.Dispose();
9098
if (_ownsDocument && Document is IDisposable disposable) disposable.Dispose();
9199
}
92100

@@ -119,8 +127,18 @@ public static DocumentContext<T> Create(
119127
throw new InvalidOperationException(
120128
"Session management is not enabled. Use --enable-sessions flag or provide a file path.");
121129

122-
var doc = sessionManager.GetDocument<T>(sessionId, identity);
123-
return new DocumentContext<T>(doc, sessionManager, sessionId, null, false, identity);
130+
var session = sessionManager.GetSession(sessionId, identity);
131+
var usageScope = session.AcquireUsage();
132+
try
133+
{
134+
var doc = session.GetDocument<T>();
135+
return new DocumentContext<T>(doc, sessionManager, sessionId, null, false, identity, usageScope);
136+
}
137+
catch
138+
{
139+
usageScope.Dispose();
140+
throw;
141+
}
124142
}
125143

126144
if (string.IsNullOrEmpty(path))

Core/Session/DocumentSession.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ public sealed class DocumentSession : IDisposable
1010
/// </summary>
1111
private readonly SemaphoreSlim _lock = new(1, 1);
1212

13+
/// <summary>
14+
/// Tracks the number of active users currently holding a usage scope on this session
15+
/// </summary>
16+
private int _activeUsers;
17+
1318
/// <summary>
1419
/// Tracks whether this session has been disposed (0 = not disposed, 1 = disposed)
1520
/// </summary>
@@ -112,6 +117,11 @@ public DateTime LastAccessedAt
112117
/// </summary>
113118
public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
114119

120+
/// <summary>
121+
/// Whether this session has active users currently executing operations
122+
/// </summary>
123+
public bool HasActiveUsers => Volatile.Read(ref _activeUsers) > 0;
124+
115125
/// <summary>
116126
/// Disposes the session and releases all resources including the document.
117127
/// Thread-safe: uses Interlocked to prevent double-dispose.
@@ -138,6 +148,33 @@ private void ThrowIfDisposed()
138148
throw new ObjectDisposedException(nameof(DocumentSession), $"Session {SessionId} has been disposed");
139149
}
140150

151+
/// <summary>
152+
/// Acquires a usage scope that prevents the session from being cleaned up while in use.
153+
/// The caller must dispose the returned scope when the operation is complete.
154+
/// </summary>
155+
/// <returns>A disposable usage scope</returns>
156+
/// <exception cref="ObjectDisposedException">Thrown when session is already disposed</exception>
157+
public IDisposable AcquireUsage()
158+
{
159+
Interlocked.Increment(ref _activeUsers);
160+
if (IsDisposed)
161+
{
162+
Interlocked.Decrement(ref _activeUsers);
163+
throw new ObjectDisposedException(nameof(DocumentSession),
164+
$"Session {SessionId} has been disposed");
165+
}
166+
167+
return new UsageScope(this);
168+
}
169+
170+
/// <summary>
171+
/// Releases a usage scope, decrementing the active user count
172+
/// </summary>
173+
private void ReleaseUsage()
174+
{
175+
Interlocked.Decrement(ref _activeUsers);
176+
}
177+
141178
/// <summary>
142179
/// Execute a synchronous operation on the document with thread-safety
143180
/// </summary>
@@ -293,4 +330,24 @@ public async Task<T> GetDocumentAsync<T>(CancellationToken cancellationToken = d
293330
_lock.Release();
294331
}
295332
}
333+
334+
/// <summary>
335+
/// Represents a usage scope that prevents the session from being cleaned up.
336+
/// Disposing this scope releases the usage count.
337+
/// </summary>
338+
private sealed class UsageScope : IDisposable
339+
{
340+
private DocumentSession? _session;
341+
342+
public UsageScope(DocumentSession session)
343+
{
344+
_session = session;
345+
}
346+
347+
public void Dispose()
348+
{
349+
var s = Interlocked.Exchange(ref _session, null);
350+
s?.ReleaseUsage();
351+
}
352+
}
296353
}

Core/Session/DocumentSessionManager.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,14 @@ private void CleanupIdleSessions(object? state)
625625
foreach (var session in allSessions)
626626
if (now - session.LastAccessedAt > timeout)
627627
{
628+
if (session.HasActiveUsers)
629+
{
630+
_logger?.LogDebug(
631+
"Skipping cleanup of idle session {SessionId} because it has active users",
632+
session.SessionId);
633+
continue;
634+
}
635+
628636
_logger?.LogInformation("Session {SessionId} timed out after {Minutes} minutes of inactivity",
629637
session.SessionId, Config.IdleTimeoutMinutes);
630638

Handlers/Email/Calendar/LoadEmailCalendarHandler.cs renamed to Handlers/Email/Calendar/GetEmailCalendarHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ namespace AsposeMcpServer.Handlers.Email.Calendar;
1010
/// Handler for loading and reading calendar appointment information from an ICS file.
1111
/// </summary>
1212
[ResultType(typeof(AppointmentEmailInfo))]
13-
public class LoadEmailCalendarHandler : OperationHandlerBase<object>
13+
public class GetEmailCalendarHandler : OperationHandlerBase<object>
1414
{
1515
/// <inheritdoc />
16-
public override string Operation => "get_info";
16+
public override string Operation => "get";
1717

1818
/// <summary>
1919
/// Loads a calendar appointment from the specified ICS file and returns its information.

Handlers/Email/Contact/LoadEmailContactHandler.cs renamed to Handlers/Email/Contact/GetEmailContactHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ namespace AsposeMcpServer.Handlers.Email.Contact;
1010
/// Handler for loading and retrieving email contact information.
1111
/// </summary>
1212
[ResultType(typeof(ContactEmailInfo))]
13-
public class LoadEmailContactHandler : OperationHandlerBase<object>
13+
public class GetEmailContactHandler : OperationHandlerBase<object>
1414
{
1515
/// <inheritdoc />
16-
public override string Operation => "get_info";
16+
public override string Operation => "get";
1717

1818
/// <summary>
1919
/// Loads an email contact from a VCF or MSG file and returns its information.

Handlers/Email/Contact/SaveEmailContactHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public override object Execute(OperationContext<object> context, OperationParame
3838
if (!File.Exists(path))
3939
throw new FileNotFoundException($"Input file not found: {path}");
4040

41-
var contact = LoadEmailContactHandler.LoadContact(path);
41+
var contact = GetEmailContactHandler.LoadContact(path);
4242

4343
var ext = format?.ToLowerInvariant() ?? Path.GetExtension(outputPath).ToLowerInvariant().TrimStart('.');
4444
contact.Save(outputPath, ext == "msg" ? ContactSaveFormat.Msg : ContactSaveFormat.VCard);

Handlers/Email/Contact/SetPhotoEmailContactHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public override object Execute(OperationContext<object> context, OperationParame
4141
if (!File.Exists(photoPath))
4242
throw new FileNotFoundException($"Photo file not found: {photoPath}");
4343

44-
var contact = LoadEmailContactHandler.LoadContact(path);
44+
var contact = GetEmailContactHandler.LoadContact(path);
4545
var photoBytes = File.ReadAllBytes(photoPath);
4646
contact.Photo = new MapiContactPhoto(photoBytes, MapiContactPhotoImageFormat.Jpeg);
4747

Handlers/Email/FileOperations/DetectFormatEmailFileHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace AsposeMcpServer.Handlers.Email.FileOperations;
1414
public class DetectFormatEmailFileHandler : OperationHandlerBase<object>
1515
{
1616
/// <inheritdoc />
17-
public override string Operation => "detect_format";
17+
public override string Operation => "detect";
1818

1919
/// <summary>
2020
/// Detects the format of an email file using Aspose.Email's format detection.

0 commit comments

Comments
 (0)