hap-cli 0.6.5__tar.gz → 0.6.7__tar.gz

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.
Files changed (73) hide show
  1. {hap_cli-0.6.5 → hap_cli-0.6.7}/PKG-INFO +4 -3
  2. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/README.md +3 -2
  3. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/README_CN.md +3 -2
  4. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/__init__.py +1 -1
  5. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/contact_cmd.py +35 -21
  6. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/department_cmd.py +26 -23
  7. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/post_cmd.py +49 -0
  8. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/contact.py +40 -22
  9. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/post.py +12 -0
  10. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/skills/SKILL.md +3 -2
  11. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_core.py +35 -23
  12. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_approval.py +1 -1
  13. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_social.py +48 -7
  14. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli.egg-info/PKG-INFO +4 -3
  15. {hap_cli-0.6.5 → hap_cli-0.6.7}/setup.py +1 -1
  16. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/__init__.py +0 -0
  17. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/app_cmd.py +0 -0
  18. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/auth_cmd.py +0 -0
  19. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/calendar_cmd.py +0 -0
  20. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/chat_cmd.py +0 -0
  21. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/instance_cmd.py +0 -0
  22. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/node_cmd.py +0 -0
  23. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/optionset_cmd.py +0 -0
  24. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/page_cmd.py +0 -0
  25. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/record_cmd.py +0 -0
  26. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/role_cmd.py +0 -0
  27. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/workflow_cmd.py +0 -0
  28. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/commands/worksheet_cmd.py +0 -0
  29. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/context.py +0 -0
  30. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/__init__.py +0 -0
  31. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/app.py +0 -0
  32. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/auth.py +0 -0
  33. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/calendar_mod.py +0 -0
  34. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/chat.py +0 -0
  35. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/department.py +0 -0
  36. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/flow_node.py +0 -0
  37. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/group.py +0 -0
  38. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/instance.py +0 -0
  39. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/optionset.py +0 -0
  40. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/page.py +0 -0
  41. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/record.py +0 -0
  42. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/response_crypto.py +0 -0
  43. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/role.py +0 -0
  44. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/session.py +0 -0
  45. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/token_crypto.py +0 -0
  46. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/workflow.py +0 -0
  47. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/core/worksheet.py +0 -0
  48. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/hap_cli.py +0 -0
  49. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/skills/__init__.py +0 -0
  50. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/__init__.py +0 -0
  51. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/conftest.py +0 -0
  52. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_full_e2e.py +0 -0
  53. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration.py +0 -0
  54. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_calendar.py +0 -0
  55. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_destructive.py +0 -0
  56. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_misc.py +0 -0
  57. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_post.py +0 -0
  58. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_workflow.py +0 -0
  59. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_integration_worksheet_extra.py +0 -0
  60. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_org_id_cli.py +0 -0
  61. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_org_id_docs.py +0 -0
  62. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_parameter_conventions.py +0 -0
  63. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/tests/test_parameter_mapping_registry.py +0 -0
  64. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/utils/__init__.py +0 -0
  65. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/utils/formatting.py +0 -0
  66. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/utils/options.py +0 -0
  67. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli/utils/parameter_mapping.py +0 -0
  68. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli.egg-info/SOURCES.txt +0 -0
  69. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli.egg-info/dependency_links.txt +0 -0
  70. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli.egg-info/entry_points.txt +0 -0
  71. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli.egg-info/requires.txt +0 -0
  72. {hap_cli-0.6.5 → hap_cli-0.6.7}/hap_cli.egg-info/top_level.txt +0 -0
  73. {hap_cli-0.6.5 → hap_cli-0.6.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hap-cli
3
- Version: 0.6.5
3
+ Version: 0.6.7
4
4
  Summary: CLI harness for MingDAO HAP - Enterprise no-code platform
5
5
  Author: hap-cli
6
6
  License: Apache-2.0
@@ -333,8 +333,8 @@ hap --json worksheet record list WORKSHEET_ID
333
333
 
334
334
  | Command | Description |
335
335
  |---|---|
336
- | `post list` | List feed posts |
337
- | `post search` | Search posts by keyword and date range |
336
+ | `post list` | List feed posts (`--topic-id` filters by topic tag) |
337
+ | `post search` | Search posts by keyword and date range (`--topic-id` filters by topic tag) |
338
338
  | `post get` | Show a post with its comment thread |
339
339
  | `post create` | Create a new post |
340
340
  | `post update` | Edit a post's content |
@@ -348,6 +348,7 @@ hap --json worksheet record list WORKSHEET_ID
348
348
  | `post pin` | Pin a post (`--hours` for duration) |
349
349
  | `post unpin` | Unpin a post |
350
350
  | `post pinned` | List pinned posts |
351
+ | `post topics` | List topic tags (autocomplete) |
351
352
 
352
353
  ### calendar — Calendar Management
353
354
 
@@ -304,8 +304,8 @@ hap --json worksheet record list WORKSHEET_ID
304
304
 
305
305
  | Command | Description |
306
306
  |---|---|
307
- | `post list` | List feed posts |
308
- | `post search` | Search posts by keyword and date range |
307
+ | `post list` | List feed posts (`--topic-id` filters by topic tag) |
308
+ | `post search` | Search posts by keyword and date range (`--topic-id` filters by topic tag) |
309
309
  | `post get` | Show a post with its comment thread |
310
310
  | `post create` | Create a new post |
311
311
  | `post update` | Edit a post's content |
@@ -319,6 +319,7 @@ hap --json worksheet record list WORKSHEET_ID
319
319
  | `post pin` | Pin a post (`--hours` for duration) |
320
320
  | `post unpin` | Unpin a post |
321
321
  | `post pinned` | List pinned posts |
322
+ | `post topics` | List topic tags (autocomplete) |
322
323
 
323
324
  ### calendar — Calendar Management
324
325
 
@@ -311,8 +311,8 @@ hap --json worksheet record list 工作表ID
311
311
 
312
312
  | 命令 | 说明 |
313
313
  |---|---|
314
- | `post list` | 列出动态帖子 |
315
- | `post search` | 按关键词和日期范围搜索帖子 |
314
+ | `post list` | 列出动态帖子(`--topic-id` 按话题标签过滤) |
315
+ | `post search` | 按关键词和日期范围搜索帖子(`--topic-id` 按话题标签过滤) |
316
316
  | `post get` | 查看帖子详情(含评论) |
317
317
  | `post create` | 创建新帖子 |
318
318
  | `post update` | 编辑帖子内容 |
@@ -326,6 +326,7 @@ hap --json worksheet record list 工作表ID
326
326
  | `post pin` | 置顶帖子(`--hours` 指定时长) |
327
327
  | `post unpin` | 取消置顶 |
328
328
  | `post pinned` | 列出置顶帖子 |
329
+ | `post topics` | 列出话题标签(自动补全) |
329
330
 
330
331
  ### calendar — 日程管理
331
332
 
@@ -1,3 +1,3 @@
1
1
  """CLI harness for MingDAO HAP (hap-cli)."""
2
2
 
3
- __version__ = "0.6.5"
3
+ __version__ = "0.6.7"
@@ -51,14 +51,33 @@ def _simplify_user(u: dict[str, Any] | None) -> dict[str, Any] | None:
51
51
  v = u.get(src)
52
52
  if v:
53
53
  out[dst] = v
54
+ # Newer endpoints (User.GetContactUserList,
55
+ # User.GetProjectResignedUserList) nest the department under
56
+ # `departmentInfo`. Flatten it here so the rendered shape stays flat.
57
+ dept = u.get("departmentInfo") or {}
58
+ if isinstance(dept, dict):
59
+ if dept.get("departmentName") and not out.get("department"):
60
+ out["department"] = dept["departmentName"]
61
+ if dept.get("departmentId"):
62
+ out["department_id"] = dept["departmentId"]
54
63
  if u.get("accountStatus") and u["accountStatus"] != 0:
55
64
  out["status"] = u["accountStatus"]
56
65
  return out
57
66
 
58
67
 
59
68
  def _simplify_search_result(raw: dict[str, Any]) -> dict[str, Any]:
60
- users = [_simplify_user(u) for u in (raw.get("userResult") or [])]
61
- return {"users": [u for u in users if u]}
69
+ """Unified envelope for the two contact-search endpoints.
70
+
71
+ Both `User.GetContactUserList` (after extracting the `users`
72
+ sub-dict in the core layer) and `User.GetProjectResignedUserList`
73
+ hand us a ``{"list": [...], "allCount": N}`` shape — project it to
74
+ ``{"total": N, "users": [...]}`` so a single renderer handles both.
75
+ """
76
+ users = [_simplify_user(u) for u in (raw.get("list") or [])]
77
+ return {
78
+ "total": raw.get("allCount", 0),
79
+ "users": [u for u in users if u],
80
+ }
62
81
 
63
82
 
64
83
  def _simplify_friends_result(raw: dict[str, Any]) -> dict[str, Any]:
@@ -151,40 +170,35 @@ def _render_friend_requests(summary: dict[str, Any]) -> None:
151
170
  @contact.command("search")
152
171
  @click.argument("keywords")
153
172
  @org_id_option()
154
- @click.option(
155
- "--range", "range_", type=int, default=None,
156
- help="Server-defined range bucket (numeric)",
157
- )
158
173
  @click.option("--page-size", "-n", default=20, help="Items per page")
159
174
  @click.option("--page", "-p", default=1, help="Page number")
160
175
  @click.option(
161
- "--filter-other/--no-filter-other", "is_filter_other", default=None,
162
- help="Filter out third-party collaborators (friends + colleagues only)",
163
- )
164
- @click.option(
165
- "--take-count/--no-take-count", "take_total_count", default=None,
166
- help="Include total count in response",
176
+ "--resigned/--no-resigned",
177
+ "resigned",
178
+ default=False,
179
+ help="Search resigned members instead of active ones",
167
180
  )
168
181
  @pass_context
169
- def contact_search(
170
- ctx, keywords, org_id, range_, page_size, page, is_filter_other, take_total_count,
171
- ):
172
- """Search contacts by keyword within an organization."""
182
+ def contact_search(ctx, keywords, org_id, page_size, page, resigned):
183
+ """Search members of an organization by keyword.
184
+
185
+ Defaults to active members. Pass --resigned to search former
186
+ members. Uses the current organization when --org-id is omitted.
187
+ """
173
188
  try:
174
189
  session = ctx.get_session()
175
190
  pid = require_api_project_id(session, org_id)
176
- raw = contact_mod.search_contacts(
191
+ fn = contact_mod.search_resigned_users if resigned else contact_mod.search_contacts
192
+ raw = fn(
177
193
  session,
178
194
  keywords,
179
195
  project_id=pid,
180
- range_=range_,
181
196
  page_index=page,
182
197
  page_size=page_size,
183
- is_filter_other=is_filter_other,
184
- take_total_count=take_total_count,
185
198
  )
186
199
  summary = _simplify_search_result(raw)
187
- ctx.output(summary, lambda d: _render_users(d, "Matches"))
200
+ header = "Resigned matches" if resigned else "Matches"
201
+ ctx.output(summary, lambda d: _render_users(d, header))
188
202
  except Exception as e:
189
203
  ctx.handle_error(e)
190
204
 
@@ -34,8 +34,8 @@ def _simplify_department(d: dict[str, Any] | None) -> dict[str, Any] | None:
34
34
  "id": d.get("departmentId") or d.get("id"),
35
35
  "name": d.get("departmentName") or d.get("name"),
36
36
  }
37
- if d.get("userCount") is not None:
38
- out["user_count"] = d.get("userCount")
37
+ # userCount from these list/tree endpoints is unreliable (always 0) —
38
+ # use `department members` for a real count. Intentionally omitted.
39
39
  if d.get("haveSubDepartment") is not None:
40
40
  out["has_children"] = bool(d.get("haveSubDepartment"))
41
41
  if d.get("disabled"):
@@ -75,13 +75,23 @@ def _simplify_department_members(raw: dict[str, Any]) -> dict[str, Any]:
75
75
  }
76
76
 
77
77
 
78
+ def _simplify_dept_search_node(node: dict[str, Any] | None) -> dict[str, Any] | None:
79
+ # SearchDeptAndUsers returns departments as a recursive tree under
80
+ # the `subs` field (distinct from GetProjectSubDepartmentTree which
81
+ # uses `subDepartments`).
82
+ base = _simplify_department(node)
83
+ if base is None or not isinstance(node, dict):
84
+ return base
85
+ subs = node.get("subs") or []
86
+ children = [c for c in (_simplify_dept_search_node(s) for s in subs) if c]
87
+ if children:
88
+ base["children"] = children
89
+ return base
90
+
91
+
78
92
  def _simplify_dept_search(raw: dict[str, Any]) -> dict[str, Any]:
79
- users = [_simplify_user(u) for u in (raw.get("users") or [])]
80
- depts = [_simplify_department(d) for d in (raw.get("departments") or [])]
81
- return {
82
- "users": [u for u in users if u],
83
- "departments": [d for d in depts if d],
84
- }
93
+ depts = [_simplify_dept_search_node(d) for d in (raw.get("departments") or [])]
94
+ return {"departments": [d for d in depts if d]}
85
95
 
86
96
 
87
97
  # ── Human-readable renderers ───────────────────────────────────────────────
@@ -94,8 +104,6 @@ def _render_departments(depts: list[dict[str, Any]], header: str = "Departments"
94
104
  click.echo(f"{header} ({len(depts)}):")
95
105
  for d in depts:
96
106
  extras = []
97
- if d.get("user_count") is not None:
98
- extras.append(f"{d['user_count']} members")
99
107
  if d.get("has_children"):
100
108
  extras.append("has sub-departments")
101
109
  if d.get("disabled"):
@@ -111,8 +119,7 @@ def _render_tree(nodes: list[dict[str, Any]], depth: int = 0) -> None:
111
119
  return
112
120
  for n in nodes:
113
121
  indent = " " * depth
114
- count = f" ({n['user_count']} members)" if n.get("user_count") is not None else ""
115
- click.echo(f"{indent}• {n.get('name') or '?'}{count}")
122
+ click.echo(f"{indent} {n.get('name') or '?'}")
116
123
  click.echo(f"{indent} id: {n.get('id')}")
117
124
  if n.get("children"):
118
125
  _render_tree(n["children"], depth + 1)
@@ -123,8 +130,6 @@ def _render_department_info(info: dict[str, Any]) -> None:
123
130
  click.echo(f"ID: {info.get('id')}")
124
131
  if info.get("parent_name") or info.get("parent_id"):
125
132
  click.echo(f"Parent: {info.get('parent_name') or ''} ({info.get('parent_id') or ''})")
126
- if info.get("user_count") is not None:
127
- click.echo(f"Members: {info['user_count']}")
128
133
  if info.get("disabled"):
129
134
  click.echo("Status: disabled")
130
135
  chargers = info.get("charge_users") or []
@@ -136,14 +141,11 @@ def _render_department_info(info: dict[str, Any]) -> None:
136
141
 
137
142
  def _render_dept_search(summary: dict[str, Any]) -> None:
138
143
  depts = summary.get("departments") or []
139
- users = summary.get("users") or []
140
- if not depts and not users:
141
- click.echo("No matches.")
144
+ if not depts:
145
+ click.echo("No matching departments.")
142
146
  return
143
- if depts:
144
- _render_departments(depts, "Matching departments")
145
- if users:
146
- _render_users({"users": users}, "Matching users")
147
+ click.echo(f"Matching departments ({len(depts)}):")
148
+ _render_tree(depts)
147
149
 
148
150
 
149
151
  # ── Commands ───────────────────────────────────────────────────────────────
@@ -249,11 +251,12 @@ def department_members(
249
251
  "--include-disabled/--no-include-disabled",
250
252
  "include_disabled",
251
253
  default=None,
252
- help="Include disabled departments/users",
254
+ help="Include disabled departments",
253
255
  )
254
256
  @pass_context
255
257
  def department_search(ctx, keywords, org_id, include_disabled):
256
- """Search departments and users within the organization."""
258
+ """Search departments by keyword. Results render as a tree showing
259
+ each match with its sub-departments."""
257
260
  try:
258
261
  session = ctx.get_session()
259
262
  pid = require_api_project_id(session, org_id)
@@ -431,6 +431,12 @@ def _normalize_scope(scope: str) -> str:
431
431
  @click.option("--start-date", default="", help="Only posts on or after this date (YYYY-MM-DD)")
432
432
  @click.option("--end-date", default="", help="Only posts on or before this date (YYYY-MM-DD)")
433
433
  @click.option("--post-type", default=-1, type=int, show_default=True, help="Filter by post type; -1 means all")
434
+ @click.option(
435
+ "--topic-id",
436
+ "topic_id",
437
+ default="",
438
+ help="Filter by topic tag id (see `hap post topics`)",
439
+ )
434
440
  @click.option("-p", "--page", default=1, show_default=True, help="Page number (1-based)")
435
441
  @click.option(
436
442
  "--cursor",
@@ -449,6 +455,7 @@ def post_list(
449
455
  start_date,
450
456
  end_date,
451
457
  post_type,
458
+ topic_id,
452
459
  page,
453
460
  last_post_auto_id,
454
461
  ):
@@ -487,6 +494,7 @@ def post_list(
487
494
  start_date=start_date,
488
495
  end_date=end_date,
489
496
  post_type=post_type,
497
+ category_id=topic_id,
490
498
  page_index=page,
491
499
  last_post_auto_id=last_post_auto_id,
492
500
  )
@@ -512,6 +520,12 @@ def post_list(
512
520
  @click.option("--post-type", default=-1, type=int, show_default=True, help="Filter by post type; -1 means all")
513
521
  @click.option("--account-id", default="", help="Filter by author member id")
514
522
  @click.option("--group-id", default="", help="Filter by group id")
523
+ @click.option(
524
+ "--topic-id",
525
+ "topic_id",
526
+ default="",
527
+ help="Filter by topic tag id (see `hap post topics`)",
528
+ )
515
529
  @click.option("-p", "--page", default=1, show_default=True, help="Page number (1-based)")
516
530
  @click.option(
517
531
  "--cursor",
@@ -530,6 +544,7 @@ def post_search(
530
544
  post_type,
531
545
  account_id,
532
546
  group_id,
547
+ topic_id,
533
548
  page,
534
549
  last_post_auto_id,
535
550
  ):
@@ -557,6 +572,7 @@ def post_search(
557
572
  account_id=account_id,
558
573
  group_id=group_id,
559
574
  post_type=post_type,
575
+ category_id=topic_id,
560
576
  page_index=page,
561
577
  last_post_auto_id=last_post_auto_id,
562
578
  )
@@ -870,3 +886,36 @@ def post_unpin(ctx, post_id):
870
886
  ))
871
887
  except Exception as e:
872
888
  ctx.handle_error(e)
889
+
890
+
891
+ def _simplify_topic(t: dict) -> dict:
892
+ return {"id": t.get("id"), "name": t.get("value")}
893
+
894
+
895
+ def _render_topics(summary: dict) -> None:
896
+ topics = summary.get("topics") or []
897
+ if not topics:
898
+ click.echo("No topics found.")
899
+ return
900
+ click.echo(f"Topics ({len(topics)}):")
901
+ for t in topics:
902
+ click.echo(f" #{t.get('name')}# id: {t.get('id')}")
903
+
904
+
905
+ @post.command("topics")
906
+ @click.option("--keywords", "-k", default="", help="Filter by keyword")
907
+ @pass_context
908
+ def post_topics(ctx, keywords):
909
+ """List topic tags (autocomplete suggestions).
910
+
911
+ Use a topic inside a post by wrapping its name in hashes
912
+ (e.g. `#周报#`) in the post message. The tag links automatically
913
+ on publish, and unknown names are created on the fly.
914
+ """
915
+ try:
916
+ session = ctx.get_session()
917
+ raw = post_mod.get_topics(session, keywords=keywords)
918
+ topics = [_simplify_topic(t) for t in (raw or []) if isinstance(t, dict)]
919
+ ctx.output({"topics": topics}, _render_topics)
920
+ except Exception as e:
921
+ ctx.handle_error(e)
@@ -26,37 +26,55 @@ from hap_cli.core.session import Session
26
26
  def search_contacts(
27
27
  session: Session,
28
28
  keywords: str,
29
- project_id: str = "",
30
- range_: Optional[int] = None,
29
+ project_id: str,
31
30
  page_index: int = 1,
32
31
  page_size: int = 20,
33
- is_filter_other: Optional[bool] = None,
34
- take_total_count: Optional[bool] = None,
35
32
  ) -> dict[str, Any]:
36
- """Search address book contacts by keyword.
37
-
38
- Wraps `AddressBook.SearchAddressbookAndDepartment`
39
- (src/api/addressBook.js L73). Despite the endpoint name, the
40
- ``departmentResult`` field is always empty on current SaaS builds
41
- only ``userResult`` actually populates. Use
42
- :func:`hap_cli.core.department.search_dept_and_users` when you need
43
- department matches. `range_` is a numeric bucket; the CLI passes
44
- it through untouched because the server controls the enum.
33
+ """Search active members of an organization by keyword.
34
+
35
+ Wraps `User.GetContactUserList`. The server response carries both
36
+ ``oftenUsers`` (frequently-contacted shortcut) and ``users`` (full
37
+ matches); only the latter is useful for a general search, so this
38
+ function returns ``raw["users"]`` with shape
39
+ ``{"list": [...], "allCount": N}`` — the same envelope produced by
40
+ :func:`search_resigned_users` so both can share simplifiers.
41
+
42
+ ``dataRange`` is fixed at 2 (whole organization) because the
43
+ alternate buckets are not exposed via the CLI.
45
44
  """
46
45
  data: dict[str, Any] = {
47
46
  "keywords": keywords,
47
+ "projectId": project_id,
48
+ "dataRange": 2,
48
49
  "pageIndex": page_index,
49
50
  "pageSize": page_size,
50
51
  }
51
- if project_id:
52
- data["projectId"] = project_id
53
- if range_ is not None:
54
- data["range"] = range_
55
- if is_filter_other is not None:
56
- data["isFilterOther"] = is_filter_other
57
- if take_total_count is not None:
58
- data["takeTotalCount"] = take_total_count
59
- return session.api_call("AddressBook", "SearchAddressbookAndDepartment", data)
52
+ raw = session.api_call("User", "GetContactUserList", data) or {}
53
+ return raw.get("users") or {"list": [], "allCount": 0}
54
+
55
+
56
+ def search_resigned_users(
57
+ session: Session,
58
+ keywords: str,
59
+ project_id: str,
60
+ page_index: int = 1,
61
+ page_size: int = 20,
62
+ ) -> dict[str, Any]:
63
+ """Search resigned members of an organization by keyword.
64
+
65
+ Wraps `User.GetProjectResignedUserList`. Returns the raw
66
+ ``{"list": [...], "allCount": N}`` envelope — matches the shape
67
+ produced by :func:`search_contacts` on purpose so callers can feed
68
+ both through one simplifier.
69
+ """
70
+ data: dict[str, Any] = {
71
+ "projectId": project_id,
72
+ "pageIndex": page_index,
73
+ "pageSize": page_size,
74
+ }
75
+ if keywords:
76
+ data["keywords"] = keywords
77
+ return session.api_call("User", "GetProjectResignedUserList", data)
60
78
 
61
79
 
62
80
  def get_friends(
@@ -330,3 +330,15 @@ def add_top_post(
330
330
  def remove_top_post(session: Session, post_id: str) -> dict[str, Any]:
331
331
  """Unpin via ``Post.RemoveTopPost``."""
332
332
  return session.api_call("Post", "RemoveTopPost", {"postId": post_id})
333
+
334
+
335
+ def get_topics(session: Session, keywords: str = "") -> list[dict[str, Any]]:
336
+ """Autocomplete topic tags via ``Category.AutoCompleteCategory``.
337
+
338
+ Returns ``[{id, value}]``. Callers don't need the id to use a tag
339
+ when posting — the server recognises ``#value#`` in the post body
340
+ and auto-links (or auto-creates) the matching topic.
341
+ """
342
+ return session.api_call(
343
+ "Category", "AutoCompleteCategory", {"keywords": keywords or ""}
344
+ )
@@ -190,8 +190,8 @@ hap department search KEYWORDS --org-id ORG_ID [--include-disabled]
190
190
 
191
191
  ### post - Feed & Post Management
192
192
  ```bash
193
- hap post list [--org-id ORG_ID] [--scope org|user|fav|group|groups|myself] [--cursor AUTO_ID] [-p N]
194
- hap post search --keywords TEXT [--org-id ORG_ID] [--start-date D] [--end-date D]
193
+ hap post list [--org-id ORG_ID] [--scope org|user|fav|group|groups|myself] [--topic-id TOPIC_ID] [--cursor AUTO_ID] [-p N]
194
+ hap post search --keywords TEXT [--org-id ORG_ID] [--start-date D] [--end-date D] [--topic-id TOPIC_ID]
195
195
  hap post get POST_ID
196
196
  hap post create --org-id ORG_ID --message TEXT [--share-group GID]... [--share-org OID]...
197
197
  hap post update POST_ID --message TEXT [--old-message TEXT]
@@ -205,6 +205,7 @@ hap post favorite POST_ID [--remove]
205
205
  hap post pin POST_ID [--hours N]
206
206
  hap post unpin POST_ID
207
207
  hap post pinned [--org-id ORG_ID]
208
+ hap post topics [--keywords K] # Hint: use #NAME# in message to tag
208
209
  ```
209
210
 
210
211
  ### calendar - Calendar & Schedule
@@ -1621,44 +1621,56 @@ class TestPage:
1621
1621
  class TestContact:
1622
1622
  @patch("hap_cli.core.session.requests.post")
1623
1623
  def test_search_contacts_defaults(self, mock_post, mock_session):
1624
- """`AddressBook.SearchAddressbookAndDepartment` minimal call.
1625
-
1626
- Real fields per src/api/addressBook.js L73: keywords, projectId,
1627
- range, pageIndex, pageSize, isFilterOther, takeTotalCount.
1628
- """
1629
- mock_post.return_value = _mock_response([{"accountId": "u1", "fullname": "Alice"}])
1624
+ """`User.GetContactUserList` defaults + `users` sub-field extraction."""
1625
+ mock_post.return_value = _mock_response({
1626
+ "oftenUsers": {"list": [{"accountId": "skip"}]},
1627
+ "users": {"list": [{"accountId": "u1", "fullname": "Alice"}], "allCount": 1},
1628
+ })
1630
1629
  from hap_cli.core import contact as contact_mod
1631
1630
  result = contact_mod.search_contacts(mock_session, "Alice", project_id="proj1")
1632
- assert result == [{"accountId": "u1", "fullname": "Alice"}]
1631
+ # Only the `users` envelope is returned; `oftenUsers` is dropped.
1632
+ assert result == {"list": [{"accountId": "u1", "fullname": "Alice"}], "allCount": 1}
1633
1633
  args = mock_post.call_args
1634
- assert "AddressBook/SearchAddressbookAndDepartment" in args[0][0]
1634
+ assert "User/GetContactUserList" in args[0][0]
1635
1635
  body = args[1]["json"]
1636
1636
  assert body["keywords"] == "Alice"
1637
1637
  assert body["projectId"] == "proj1"
1638
- # Defaults
1638
+ assert body["dataRange"] == 2
1639
1639
  assert body["pageIndex"] == 1
1640
1640
  assert body["pageSize"] == 20
1641
1641
 
1642
1642
  @patch("hap_cli.core.session.requests.post")
1643
- def test_search_contacts_full_fields(self, mock_post, mock_session):
1644
- mock_post.return_value = _mock_response({"list": [], "allCount": 0})
1643
+ def test_search_contacts_empty_envelope(self, mock_post, mock_session):
1644
+ """Missing `users` sub-field degrades gracefully to an empty envelope."""
1645
+ mock_post.return_value = _mock_response({})
1645
1646
  from hap_cli.core import contact as contact_mod
1646
- contact_mod.search_contacts(
1647
- mock_session,
1648
- keywords="Zhang",
1649
- project_id="proj1",
1650
- range_=10,
1651
- page_index=2,
1652
- page_size=50,
1653
- is_filter_other=True,
1654
- take_total_count=True,
1647
+ result = contact_mod.search_contacts(
1648
+ mock_session, keywords="Zhang", project_id="proj1", page_index=2, page_size=50,
1655
1649
  )
1650
+ assert result == {"list": [], "allCount": 0}
1656
1651
  body = mock_post.call_args[1]["json"]
1657
- assert body["range"] == 10
1658
1652
  assert body["pageIndex"] == 2
1659
1653
  assert body["pageSize"] == 50
1660
- assert body["isFilterOther"] is True
1661
- assert body["takeTotalCount"] is True
1654
+
1655
+ @patch("hap_cli.core.session.requests.post")
1656
+ def test_search_resigned_users(self, mock_post, mock_session):
1657
+ """`User.GetProjectResignedUserList` — shape matches active search."""
1658
+ mock_post.return_value = _mock_response({
1659
+ "list": [{"accountId": "r1", "fullname": "Gone"}],
1660
+ "allCount": 1,
1661
+ })
1662
+ from hap_cli.core import contact as contact_mod
1663
+ result = contact_mod.search_resigned_users(
1664
+ mock_session, keywords="Gone", project_id="proj1", page_index=2, page_size=50,
1665
+ )
1666
+ assert result == {"list": [{"accountId": "r1", "fullname": "Gone"}], "allCount": 1}
1667
+ args = mock_post.call_args
1668
+ assert "User/GetProjectResignedUserList" in args[0][0]
1669
+ body = args[1]["json"]
1670
+ assert body["keywords"] == "Gone"
1671
+ assert body["projectId"] == "proj1"
1672
+ assert body["pageIndex"] == 2
1673
+ assert body["pageSize"] == 50
1662
1674
 
1663
1675
  @patch("hap_cli.core.session.requests.post")
1664
1676
  def test_get_friends_full_fields(self, mock_post, mock_session):
@@ -60,7 +60,7 @@ def _dummy_account_id(integration_session, project_id):
60
60
  res = contact.search_contacts(
61
61
  integration_session, project_id=project_id, keywords="a", page_size=1
62
62
  )
63
- users = (res or {}).get("userResult") or []
63
+ users = (res or {}).get("list") or []
64
64
  if not users:
65
65
  pytest.skip("project has no users matching 'a' — cannot run delegation tests")
66
66
  return users[0].get("accountId")
@@ -39,19 +39,26 @@ class TestChat:
39
39
  class TestContact:
40
40
 
41
41
  def test_01_search(self, integration_session, project_id):
42
- """Contact search returns ``{userResult}``. The endpoint is
43
- named ``SearchAddressbookAndDepartment`` and also emits an
44
- always-empty ``departmentResult`` alongside it — the CLI only
45
- surfaces the user matches, but both fields remain in the raw
46
- envelope, so we assert on ``userResult`` only."""
42
+ """`User.GetContactUserList` unified ``{list, allCount}`` envelope."""
47
43
  result = contact.search_contacts(
48
44
  integration_session,
49
45
  project_id=project_id,
50
46
  keywords="a",
51
47
  page_size=5,
52
48
  )
53
- assert_dict_like(result, ["userResult"])
54
- assert isinstance(result["userResult"], list)
49
+ assert_dict_like(result, ["list"])
50
+ assert isinstance(result["list"], list)
51
+
52
+ def test_01b_search_resigned(self, integration_session, project_id):
53
+ """`User.GetProjectResignedUserList` — same envelope as active search."""
54
+ result = contact.search_resigned_users(
55
+ integration_session,
56
+ project_id=project_id,
57
+ keywords="",
58
+ page_size=5,
59
+ )
60
+ assert_dict_like(result, ["list"])
61
+ assert isinstance(result["list"], list)
55
62
 
56
63
  def test_02_resolve(self, integration_session):
57
64
  """AddressBook.GetUserAddressbookByKeywords returns a paged dict."""
@@ -146,5 +153,39 @@ class TestPost:
146
153
  f"GetTopPosts expected list, got {type(result).__name__}"
147
154
  )
148
155
 
156
+ def test_04_topics(self, integration_session):
157
+ """Category.AutoCompleteCategory returns ``[{id, value}]``."""
158
+ result = post.get_topics(integration_session)
159
+ assert isinstance(result, list)
160
+ for t in result:
161
+ assert "id" in t and "value" in t
162
+
163
+ def test_05_topics_keyword_filter(self, integration_session):
164
+ """Keyword filter narrows the suggestions."""
165
+ result = post.get_topics(integration_session, keywords="周")
166
+ assert isinstance(result, list)
167
+ # Every hit should match the keyword substring
168
+ for t in result:
169
+ assert "周" in (t.get("value") or "")
170
+
171
+ def test_06_list_by_topic(self, integration_session):
172
+ """`catId` filter: every returned post carries that topic."""
173
+ topics = post.get_topics(integration_session, keywords="周")
174
+ if not topics:
175
+ pytest.skip("no topic tags available on this server")
176
+ topic_id = topics[0]["id"]
177
+ result = post.get_post_list(
178
+ integration_session,
179
+ list_type="project",
180
+ category_id=topic_id,
181
+ )
182
+ assert_dict_like(result, ["postList"])
183
+ for p in result["postList"]:
184
+ cats = p.get("categories") or []
185
+ ids = {c.get("catID") or c.get("catId") or c.get("id") for c in cats}
186
+ assert topic_id in ids, (
187
+ f"post {p.get('postID')} missing topic {topic_id}; got {ids}"
188
+ )
189
+
149
190
 
150
191
  # Calendar coverage lives in ``test_integration_calendar.py``.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hap-cli
3
- Version: 0.6.5
3
+ Version: 0.6.7
4
4
  Summary: CLI harness for MingDAO HAP - Enterprise no-code platform
5
5
  Author: hap-cli
6
6
  License: Apache-2.0
@@ -333,8 +333,8 @@ hap --json worksheet record list WORKSHEET_ID
333
333
 
334
334
  | Command | Description |
335
335
  |---|---|
336
- | `post list` | List feed posts |
337
- | `post search` | Search posts by keyword and date range |
336
+ | `post list` | List feed posts (`--topic-id` filters by topic tag) |
337
+ | `post search` | Search posts by keyword and date range (`--topic-id` filters by topic tag) |
338
338
  | `post get` | Show a post with its comment thread |
339
339
  | `post create` | Create a new post |
340
340
  | `post update` | Edit a post's content |
@@ -348,6 +348,7 @@ hap --json worksheet record list WORKSHEET_ID
348
348
  | `post pin` | Pin a post (`--hours` for duration) |
349
349
  | `post unpin` | Unpin a post |
350
350
  | `post pinned` | List pinned posts |
351
+ | `post topics` | List topic tags (autocomplete) |
351
352
 
352
353
  ### calendar — Calendar Management
353
354
 
@@ -4,7 +4,7 @@ from setuptools import setup, find_packages
4
4
 
5
5
  setup(
6
6
  name="hap-cli",
7
- version="0.6.5",
7
+ version="0.6.7",
8
8
  description="CLI harness for MingDAO HAP - Enterprise no-code platform",
9
9
  long_description=open("hap_cli/README.md").read(),
10
10
  long_description_content_type="text/markdown",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes