auto backup 2026-07-02 11:00
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查看我今天/本周/指定日期的考勤记录(自动获取 userId)
|
||||
|
||||
用法:
|
||||
python attendance_my_record.py # 今天
|
||||
python attendance_my_record.py today # 今天
|
||||
python attendance_my_record.py 2026-03-10 # 指定日期
|
||||
python attendance_my_record.py --dry-run # 仅显示命令
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Any, Optional
|
||||
|
||||
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_my_user_id(dry_run: bool = False) -> Optional[str]:
|
||||
data = run_dws([
|
||||
'contact', 'user', 'get-self', '--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run:
|
||||
return '<MY_USER_ID>'
|
||||
if not data or not isinstance(data, dict):
|
||||
return None
|
||||
return data.get('userId') or data.get('userid')
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
args = [a for a in sys.argv[1:] if a != '--dry-run']
|
||||
|
||||
date_str = args[0] if args else 'today'
|
||||
if date_str == 'today':
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
elif not DATE_PATTERN.match(date_str):
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
print('🔍 获取当前用户信息...')
|
||||
user_id = get_my_user_id(dry_run=dry_run)
|
||||
if not user_id and not dry_run:
|
||||
print('错误:无法获取当前用户 ID')
|
||||
sys.exit(1)
|
||||
|
||||
print(f'📊 查询 {date_str} 考勤记录...\n')
|
||||
data = run_dws([
|
||||
'attendance', 'record', 'get',
|
||||
'--user', user_id or '<MY_USER_ID>',
|
||||
'--date', date_str,
|
||||
'--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
if not data:
|
||||
print('未查到考勤记录')
|
||||
return
|
||||
|
||||
print(f"📋 考勤记录 ({date_str})")
|
||||
print('=' * 40)
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查询团队成员本周排班和出勤统计
|
||||
|
||||
用法:
|
||||
python attendance_team_shift.py --users userId1,userId2,userId3
|
||||
python attendance_team_shift.py --users userId1,userId2 \
|
||||
--from 2026-03-10 --to 2026-03-14
|
||||
python attendance_team_shift.py --users userId1 --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_week_range():
|
||||
today = datetime.now()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
friday = monday + timedelta(days=4)
|
||||
return monday.strftime('%Y-%m-%d'), friday.strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='查询团队成员排班和出勤统计'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--users', required=True, help='用户 ID 列表,逗号分隔'
|
||||
)
|
||||
mon, fri = get_week_range()
|
||||
parser.add_argument('--from', dest='from_date', default=mon)
|
||||
parser.add_argument('--to', dest='to_date', default=fri)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
user_count = len(args.users.split(','))
|
||||
if user_count > 50:
|
||||
print('错误:最多查询 50 人')
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📊 团队排班查询 ({args.from_date} ~ {args.to_date})")
|
||||
print(f" 人数: {user_count}")
|
||||
print('=' * 50)
|
||||
|
||||
print('\n🔍 查询排班信息...')
|
||||
data = run_dws([
|
||||
'attendance', 'shift', 'list',
|
||||
'--users', args.users,
|
||||
'--start', args.from_date,
|
||||
'--end', args.to_date,
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
return
|
||||
if not data:
|
||||
print('未查到排班信息')
|
||||
return
|
||||
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量添加字段到钉钉 AI 表格数据表(新版 schema)
|
||||
|
||||
用法:
|
||||
python bulk_add_fields.py <baseId> <tableId> fields.json
|
||||
|
||||
fields.json 格式:
|
||||
[
|
||||
{"fieldName": "字段 1", "type": "text"},
|
||||
{"fieldName": "字段 2", "type": "number", "config": {"formatter": "INT"}},
|
||||
{"fieldName": "字段 3", "type": "singleSelect", "config": {"options": [{"name": "高"}]}}
|
||||
]
|
||||
|
||||
兼容写法:
|
||||
- name 会自动映射为 fieldName
|
||||
- phone 会自动映射为 telephone
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Dict, Any, Optional, Tuple
|
||||
|
||||
JsonData = Union[List[Any], Dict[str, Any]]
|
||||
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
ALLOWED_FILE_EXTENSIONS = ['.json']
|
||||
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
|
||||
ALLOWED_FIELD_TYPES = {
|
||||
'text', 'number', 'singleSelect', 'multipleSelect', 'date', 'currency',
|
||||
'user', 'department', 'group', 'progress', 'rating', 'checkbox',
|
||||
'attachment', 'url', 'richText', 'telephone', 'email', 'idCard',
|
||||
'barcode', 'geolocation', 'primaryDoc', 'formula', 'unidirectionalLink',
|
||||
'bidirectionalLink', 'creator', 'lastModifier', 'createdTime',
|
||||
'lastModifiedTime',
|
||||
}
|
||||
FIELD_TYPE_ALIASES = {
|
||||
'phone': 'telephone',
|
||||
}
|
||||
|
||||
|
||||
def resolve_safe_path(path: str, allowed_root: Optional[str] = None) -> Path:
|
||||
if allowed_root is None:
|
||||
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
|
||||
|
||||
allowed_root = Path(allowed_root).resolve()
|
||||
target_path = (
|
||||
Path(path).resolve()
|
||||
if Path(path).is_absolute()
|
||||
else (Path.cwd() / path).resolve()
|
||||
)
|
||||
|
||||
try:
|
||||
target_path.relative_to(allowed_root)
|
||||
return target_path
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"路径超出允许范围:{path}\n"
|
||||
f"目标路径:{target_path}\n"
|
||||
f"允许根目录:{allowed_root}\n"
|
||||
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
|
||||
)
|
||||
|
||||
|
||||
def validate_resource_id(resource_id: str) -> bool:
|
||||
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
|
||||
|
||||
|
||||
def validate_file_extension(filename: str, allowed_extensions: list) -> bool:
|
||||
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
|
||||
|
||||
|
||||
def safe_json_load(file_path: Path, max_size: int = MAX_FILE_SIZE) -> JsonData:
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > max_size:
|
||||
raise ValueError(
|
||||
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
|
||||
)
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def normalize_field_config(field: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized = dict(field)
|
||||
if 'fieldName' not in normalized and 'name' in normalized:
|
||||
normalized['fieldName'] = normalized.pop('name')
|
||||
normalized['type'] = FIELD_TYPE_ALIASES.get(
|
||||
normalized.get('type', 'text'), normalized.get('type', 'text')
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_field_config(field: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
if not isinstance(field, dict):
|
||||
return False, '字段配置必须是对象'
|
||||
|
||||
field = normalize_field_config(field)
|
||||
|
||||
if 'fieldName' not in field:
|
||||
return False, '缺少必需字段:fieldName'
|
||||
if not isinstance(field['fieldName'], str) or not field['fieldName'].strip():
|
||||
return False, 'fieldName 必须是非空字符串'
|
||||
|
||||
field_type = field.get('type', 'text')
|
||||
if field_type not in ALLOWED_FIELD_TYPES:
|
||||
return False, f"不支持的字段类型:{field_type}"
|
||||
|
||||
config = field.get('config')
|
||||
if config is not None and not isinstance(config, dict):
|
||||
return False, 'config 必须是对象'
|
||||
|
||||
if field_type in {'singleSelect', 'multipleSelect'}:
|
||||
options = (config or {}).get('options')
|
||||
if not options or not isinstance(options, list):
|
||||
return False, (
|
||||
'singleSelect / multipleSelect 必须提供 config.options 数组'
|
||||
)
|
||||
|
||||
if field_type in {'unidirectionalLink', 'bidirectionalLink'}:
|
||||
linked_sheet_id = (config or {}).get('linkedSheetId')
|
||||
if not linked_sheet_id or not validate_resource_id(linked_sheet_id):
|
||||
return False, '关联字段必须提供合法的 config.linkedSheetId'
|
||||
|
||||
return True, ''
|
||||
|
||||
|
||||
def build_fields_json(fields: List[Dict[str, Any]]) -> str:
|
||||
"""构建 --fields 参数的 JSON 字符串。"""
|
||||
payload_fields = []
|
||||
for field in fields:
|
||||
normalized = normalize_field_config(field)
|
||||
item: Dict[str, Any] = {
|
||||
'fieldName': normalized['fieldName'].strip(),
|
||||
'type': normalized.get('type', 'text'),
|
||||
}
|
||||
if 'config' in normalized and normalized['config'] is not None:
|
||||
item['config'] = normalized['config']
|
||||
payload_fields.append(item)
|
||||
return json.dumps(payload_fields, ensure_ascii=False)
|
||||
|
||||
|
||||
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
|
||||
if not args:
|
||||
print('错误:空命令')
|
||||
return None
|
||||
|
||||
cmd = ['dws'] + args
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}")
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"无法解析响应:{result.stdout[:200]}...")
|
||||
print(f"JSON 解析错误:{e}")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
print('错误:命令执行超时(60 秒)')
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
print('错误:未找到 dws 命令,请确认已安装')
|
||||
return None
|
||||
|
||||
|
||||
def bulk_add_fields(
|
||||
base_id: str, table_id: str, fields_file: str
|
||||
) -> bool:
|
||||
try:
|
||||
safe_path = resolve_safe_path(fields_file)
|
||||
except ValueError as e:
|
||||
print(f"路径验证失败:{e}")
|
||||
return False
|
||||
|
||||
if not validate_file_extension(fields_file, ALLOWED_FILE_EXTENSIONS):
|
||||
print(f"错误:只允许 {', '.join(ALLOWED_FILE_EXTENSIONS)} 文件")
|
||||
return False
|
||||
if not safe_path.exists():
|
||||
print(f"错误:文件不存在:{safe_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
fields = safe_json_load(safe_path)
|
||||
except ValueError as e:
|
||||
print(f"错误:{e}")
|
||||
return False
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"错误:JSON 格式无效:{e}")
|
||||
return False
|
||||
|
||||
if not isinstance(fields, list) or not fields:
|
||||
print('错误:fields.json 必须是非空 JSON 数组')
|
||||
return False
|
||||
if len(fields) > 15:
|
||||
print('错误:单次最多创建 15 个字段,请拆分后重试')
|
||||
return False
|
||||
|
||||
for i, field in enumerate(fields):
|
||||
valid, error = validate_field_config(field)
|
||||
if not valid:
|
||||
print(f"错误:字段 #{i+1} 配置无效:{error}")
|
||||
return False
|
||||
|
||||
fields_json = build_fields_json(fields)
|
||||
result = run_dws([
|
||||
'aitable', 'field', 'create',
|
||||
'--base-id', base_id,
|
||||
'--table-id', table_id,
|
||||
'--fields', fields_json,
|
||||
'--format', 'json',
|
||||
])
|
||||
|
||||
if not result:
|
||||
return False
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
print(__doc__)
|
||||
print('用法示例:')
|
||||
print(' python bulk_add_fields.py basexxx tablexxx fields.json')
|
||||
sys.exit(1)
|
||||
|
||||
base_id = sys.argv[1]
|
||||
table_id = sys.argv[2]
|
||||
fields_file = sys.argv[3]
|
||||
|
||||
if not validate_resource_id(base_id):
|
||||
print('错误:无效的 baseId 格式')
|
||||
sys.exit(1)
|
||||
if not validate_resource_id(table_id):
|
||||
print('错误:无效的 tableId 格式')
|
||||
sys.exit(1)
|
||||
|
||||
success = bulk_add_fields(base_id, table_id, fields_file)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查询多人共同空闲时段,推荐最佳会议时间
|
||||
|
||||
用法:
|
||||
python calendar_free_slot_finder.py \
|
||||
--users userId1,userId2,userId3 \
|
||||
--date 2026-03-15 \
|
||||
--duration 60
|
||||
|
||||
python calendar_free_slot_finder.py \
|
||||
--users userId1,userId2 \
|
||||
--date 2026-03-15 \
|
||||
--start-hour 9 --end-hour 18 \
|
||||
--duration 30 --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
SLOT_STEP_MIN = 30
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def fmt_iso(dt: datetime) -> str:
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
|
||||
|
||||
|
||||
def parse_busy_intervals(
|
||||
data: Any,
|
||||
) -> List[Tuple[datetime, datetime]]:
|
||||
intervals = []
|
||||
if not data:
|
||||
return intervals
|
||||
items = []
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
for user_data in data.values():
|
||||
if isinstance(user_data, list):
|
||||
items.extend(user_data)
|
||||
elif isinstance(user_data, dict):
|
||||
items.extend(
|
||||
user_data.get('busyTimes', [])
|
||||
)
|
||||
for item in items:
|
||||
start_str = item.get('startTime') or item.get('start', '')
|
||||
end_str = item.get('endTime') or item.get('end', '')
|
||||
if not start_str or not end_str:
|
||||
continue
|
||||
for fmt in (
|
||||
'%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M%z',
|
||||
):
|
||||
try:
|
||||
s = datetime.strptime(start_str, fmt)
|
||||
e = datetime.strptime(end_str, fmt)
|
||||
if s.tzinfo is None:
|
||||
s = s.replace(tzinfo=TZ)
|
||||
if e.tzinfo is None:
|
||||
e = e.replace(tzinfo=TZ)
|
||||
intervals.append((s, e))
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
return intervals
|
||||
|
||||
|
||||
def find_free_slots(
|
||||
day_start: datetime, day_end: datetime,
|
||||
busy: List[Tuple[datetime, datetime]],
|
||||
duration_min: int,
|
||||
) -> List[Tuple[datetime, datetime]]:
|
||||
busy_sorted = sorted(busy, key=lambda x: x[0])
|
||||
merged: List[Tuple[datetime, datetime]] = []
|
||||
for s, e in busy_sorted:
|
||||
if merged and s <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
||||
else:
|
||||
merged.append((s, e))
|
||||
|
||||
free: List[Tuple[datetime, datetime]] = []
|
||||
cursor = day_start
|
||||
for bs, be in merged:
|
||||
if cursor < bs:
|
||||
gap = (bs - cursor).total_seconds() / 60
|
||||
if gap >= duration_min:
|
||||
free.append((cursor, bs))
|
||||
cursor = max(cursor, be)
|
||||
if cursor < day_end:
|
||||
gap = (day_end - cursor).total_seconds() / 60
|
||||
if gap >= duration_min:
|
||||
free.append((cursor, day_end))
|
||||
return free
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='查询多人共同空闲时段'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--users', required=True, help='用户 ID 列表,逗号分隔'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--date', required=True, help='查询日期 YYYY-MM-DD'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--duration', type=int, default=60,
|
||||
help='会议时长(分钟),默认 60',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--start-hour', type=int, default=9,
|
||||
help='工作日开始小时,默认 9',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--end-hour', type=int, default=18,
|
||||
help='工作日结束小时,默认 18',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run', action='store_true', help='仅显示命令'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
date = datetime.strptime(args.date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
print('错误:日期格式应为 YYYY-MM-DD')
|
||||
sys.exit(1)
|
||||
|
||||
day_start = date.replace(
|
||||
hour=args.start_hour, tzinfo=TZ
|
||||
)
|
||||
day_end = date.replace(hour=args.end_hour, tzinfo=TZ)
|
||||
|
||||
data = run_dws([
|
||||
'calendar', 'busy', 'search',
|
||||
'--users', args.users,
|
||||
'--start', fmt_iso(day_start),
|
||||
'--end', fmt_iso(day_end),
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
return
|
||||
|
||||
busy = parse_busy_intervals(data)
|
||||
free = find_free_slots(day_start, day_end, busy, args.duration)
|
||||
|
||||
users_list = args.users.split(',')
|
||||
print(f"\n🕐 空闲时段查询 ({args.date})")
|
||||
print(f" 参与人: {len(users_list)} 人")
|
||||
print(f" 会议时长: {args.duration} 分钟")
|
||||
print(f" 工作时间: {args.start_hour}:00 ~ "
|
||||
f"{args.end_hour}:00")
|
||||
print('=' * 50)
|
||||
|
||||
if not free:
|
||||
print(' ❌ 该日无共同空闲时段')
|
||||
return
|
||||
|
||||
print(f"\n✅ 找到 {len(free)} 个可用时段:\n")
|
||||
for i, (s, e) in enumerate(free, 1):
|
||||
gap_min = int((e - s).total_seconds() / 60)
|
||||
label = '⭐ 推荐' if i == 1 else f' 备选{i-1}'
|
||||
print(f" {label} {s.strftime('%H:%M')} ~ "
|
||||
f"{e.strftime('%H:%M')} ({gap_min}分钟)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
一键创建日程 + 添加参与者 + 搜索并预定空闲会议室
|
||||
|
||||
用法:
|
||||
python calendar_schedule_meeting.py \
|
||||
--title "Q1 复盘会" \
|
||||
--start "2026-03-15T14:00" \
|
||||
--end "2026-03-15T15:00" \
|
||||
--users userId1,userId2 \
|
||||
--book-room
|
||||
|
||||
python calendar_schedule_meeting.py --dry-run \
|
||||
--title "测试" --start "2026-03-15T14:00" --end "2026-03-15T15:00"
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return {'dry_run': True}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" ✗ 错误:{result.stderr.strip()}")
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f" ✗ 错误:{e}")
|
||||
return None
|
||||
|
||||
|
||||
def normalize_time(time_str: str) -> str:
|
||||
for fmt in ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M',
|
||||
'%Y-%m-%dT%H:%M:%S'):
|
||||
try:
|
||||
dt = datetime.strptime(time_str, fmt)
|
||||
dt = dt.replace(tzinfo=TZ)
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
|
||||
except ValueError:
|
||||
continue
|
||||
if '+' in time_str or time_str.endswith('Z'):
|
||||
return time_str
|
||||
raise ValueError(f"无法解析时间:{time_str}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='一键创建日程 + 添加参与者 + 预定会议室'
|
||||
)
|
||||
parser.add_argument('--title', required=True, help='日程标题')
|
||||
parser.add_argument('--start', required=True, help='开始时间')
|
||||
parser.add_argument('--end', required=True, help='结束时间')
|
||||
parser.add_argument('--desc', default='', help='日程描述')
|
||||
parser.add_argument('--users', default='', help='参与者 userId')
|
||||
parser.add_argument(
|
||||
'--book-room', action='store_true', help='自动预定会议室'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run', action='store_true', help='仅显示命令'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
start_iso = normalize_time(args.start)
|
||||
end_iso = normalize_time(args.end)
|
||||
except ValueError as e:
|
||||
print(f"错误:{e}")
|
||||
sys.exit(1)
|
||||
|
||||
print('📅 创建日程...')
|
||||
create_args = [
|
||||
'calendar', 'event', 'create',
|
||||
'--title', args.title,
|
||||
'--start', start_iso,
|
||||
'--end', end_iso,
|
||||
'--format', 'json',
|
||||
]
|
||||
if args.desc:
|
||||
create_args.extend(['--desc', args.desc])
|
||||
|
||||
result = run_dws(create_args, dry_run=args.dry_run)
|
||||
if not result:
|
||||
sys.exit(1)
|
||||
|
||||
event_id = None
|
||||
if not args.dry_run and isinstance(result, dict):
|
||||
event_id = result.get('eventId') or result.get('id')
|
||||
print(f" ✓ 日程已创建" +
|
||||
(f" (eventId: {event_id})" if event_id else ""))
|
||||
|
||||
if args.users and event_id:
|
||||
print('\n👥 添加参与者...')
|
||||
r = run_dws([
|
||||
'calendar', 'participant', 'add',
|
||||
'--event', event_id,
|
||||
'--users', args.users,
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
if r:
|
||||
print(f" ✓ 已添加参与者: {args.users}")
|
||||
elif args.users and args.dry_run:
|
||||
run_dws([
|
||||
'calendar', 'participant', 'add',
|
||||
'--event', '<EVENT_ID>',
|
||||
'--users', args.users,
|
||||
'--format', 'json',
|
||||
], dry_run=True)
|
||||
|
||||
if args.book_room:
|
||||
print('\n🏢 搜索空闲会议室...')
|
||||
rooms_data = run_dws([
|
||||
'calendar', 'room', 'search',
|
||||
'--start', start_iso,
|
||||
'--end', end_iso,
|
||||
'--available',
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
|
||||
if not args.dry_run and rooms_data:
|
||||
rooms = (rooms_data if isinstance(rooms_data, list)
|
||||
else rooms_data.get('rooms', []))
|
||||
if rooms:
|
||||
room = rooms[0]
|
||||
room_id = room.get('roomId') or room.get('id')
|
||||
room_name = room.get('roomName') or room.get('name')
|
||||
print(f" 找到空闲会议室: {room_name}")
|
||||
if event_id and room_id:
|
||||
r = run_dws([
|
||||
'calendar', 'room', 'add',
|
||||
'--event', event_id,
|
||||
'--rooms', str(room_id),
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
if r:
|
||||
print(f" ✓ 已预定: {room_name}")
|
||||
else:
|
||||
print(' ⚠ 该时段无空闲会议室')
|
||||
|
||||
print('\n✅ 完成!')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查看今天/明天/本周的日程安排
|
||||
|
||||
用法:
|
||||
python calendar_today_agenda.py # 今天
|
||||
python calendar_today_agenda.py today # 今天
|
||||
python calendar_today_agenda.py tomorrow # 明天
|
||||
python calendar_today_agenda.py week # 本周
|
||||
python calendar_today_agenda.py --dry-run # 仅显示命令
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_range(scope: str):
|
||||
now = datetime.now(TZ)
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if scope == 'today':
|
||||
return today, today + timedelta(days=1)
|
||||
elif scope == 'tomorrow':
|
||||
t = today + timedelta(days=1)
|
||||
return t, t + timedelta(days=1)
|
||||
elif scope == 'week':
|
||||
ws = today - timedelta(days=today.weekday())
|
||||
return ws, ws + timedelta(days=7)
|
||||
return today, today + timedelta(days=1)
|
||||
|
||||
|
||||
def fmt_iso(dt: datetime) -> str:
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
|
||||
|
||||
|
||||
def fmt_time(iso_str: str) -> str:
|
||||
if not iso_str:
|
||||
return '??:??'
|
||||
try:
|
||||
for fmt in ('%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S'):
|
||||
try:
|
||||
dt = datetime.strptime(iso_str, fmt)
|
||||
return dt.strftime('%H:%M')
|
||||
except ValueError:
|
||||
continue
|
||||
return iso_str[:16]
|
||||
except Exception:
|
||||
return iso_str[:16]
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
args = [a for a in sys.argv[1:] if a != '--dry-run']
|
||||
scope = args[0] if args else 'today'
|
||||
if scope not in ('today', 'tomorrow', 'week'):
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
start, end = get_range(scope)
|
||||
data = run_dws([
|
||||
'calendar', 'event', 'list',
|
||||
'--start', fmt_iso(start),
|
||||
'--end', fmt_iso(end),
|
||||
'--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
events = []
|
||||
if isinstance(data, list):
|
||||
events = data
|
||||
elif isinstance(data, dict):
|
||||
events = data.get('events', data.get('result', []))
|
||||
|
||||
label = {'today': '今天', 'tomorrow': '明天', 'week': '本周'
|
||||
}.get(scope, scope)
|
||||
print(f"\n📅 {label}日程 ({start.strftime('%m-%d')} ~ "
|
||||
f"{end.strftime('%m-%d')})")
|
||||
print('=' * 50)
|
||||
|
||||
if not events:
|
||||
print(' ✅ 暂无日程,自由安排!')
|
||||
return
|
||||
|
||||
for e in events:
|
||||
title = e.get('summary') or e.get('title', '无标题')
|
||||
s = e.get('start', {})
|
||||
ed = e.get('end', {})
|
||||
start_t = fmt_time(
|
||||
s.get('dateTime', '') if isinstance(s, dict) else str(s)
|
||||
)
|
||||
end_t = fmt_time(
|
||||
ed.get('dateTime', '') if isinstance(ed, dict) else str(ed)
|
||||
)
|
||||
loc = e.get('location', {})
|
||||
loc_str = (loc.get('displayName', '')
|
||||
if isinstance(loc, dict) else str(loc or ''))
|
||||
line = f" 🕐 {start_t}-{end_t} {title}"
|
||||
if loc_str:
|
||||
line += f" 📍{loc_str}"
|
||||
print(line)
|
||||
|
||||
print(f"\n合计: {len(events)} 场日程")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
按部门名称搜索并列出所有成员(自动 deptId 解析)
|
||||
|
||||
用法:
|
||||
python contact_dept_members.py --query "技术部"
|
||||
python contact_dept_members.py --query "产品" --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='按部门名称搜索并列出所有成员'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--query', required=True, help='部门名称关键词'
|
||||
)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f'🔍 搜索部门: {args.query}')
|
||||
dept_data = run_dws([
|
||||
'contact', 'dept', 'search',
|
||||
'--query', args.query, '--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
run_dws([
|
||||
'contact', 'dept', 'list-members',
|
||||
'--ids', '<DEPT_ID>', '--format', 'json',
|
||||
], dry_run=True)
|
||||
return
|
||||
|
||||
if not dept_data:
|
||||
print('未找到匹配部门')
|
||||
sys.exit(1)
|
||||
|
||||
depts = (dept_data if isinstance(dept_data, list)
|
||||
else dept_data.get('result', dept_data.get('items', [])))
|
||||
if not depts:
|
||||
print('未找到匹配部门')
|
||||
sys.exit(1)
|
||||
|
||||
for dept in depts:
|
||||
dept_id = dept.get('id') or dept.get('deptId')
|
||||
dept_name = dept.get('name') or dept.get('deptName', '未知')
|
||||
if not dept_id:
|
||||
continue
|
||||
|
||||
print(f"\n📂 {dept_name} (ID: {dept_id})")
|
||||
print('-' * 40)
|
||||
|
||||
members_data = run_dws([
|
||||
'contact', 'dept', 'list-members',
|
||||
'--ids', str(dept_id), '--format', 'json',
|
||||
])
|
||||
if not members_data:
|
||||
print(' 无法获取成员列表')
|
||||
continue
|
||||
|
||||
members = (members_data if isinstance(members_data, list)
|
||||
else members_data.get('result',
|
||||
members_data.get('userlist', [])))
|
||||
if not members:
|
||||
print(' (暂无成员)')
|
||||
continue
|
||||
|
||||
for m in members:
|
||||
name = m.get('name') or m.get('userName', '未知')
|
||||
title = m.get('title') or m.get('position', '')
|
||||
uid = m.get('userId') or m.get('userid', '')
|
||||
line = f" 👤 {name}"
|
||||
if title:
|
||||
line += f" ({title})"
|
||||
if uid:
|
||||
line += f" [ID: {uid}]"
|
||||
print(line)
|
||||
|
||||
print(f" 共 {len(members)} 人")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 CSV / JSON 批量导入记录到钉钉 AI 表格(新版 schema)
|
||||
|
||||
用法:
|
||||
python import_records.py <baseId> <tableId> data.csv [batch_size]
|
||||
python import_records.py <baseId> <tableId> data.json [batch_size]
|
||||
|
||||
说明:
|
||||
- CSV 表头默认视为 fieldId
|
||||
- JSON 支持两种格式:
|
||||
1. [{"cells": {"fldxxx": "value"}}, ...]
|
||||
2. [{"fldxxx": "value"}, ...] # 会自动包装成 cells
|
||||
"""
|
||||
|
||||
import sys
|
||||
import csv
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Dict, Any, Optional, Tuple
|
||||
|
||||
JsonData = Union[List[Any], Dict[str, Any]]
|
||||
RecordDict = Dict[str, str]
|
||||
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
ALLOWED_CSV_EXTENSIONS = ['.csv']
|
||||
ALLOWED_JSON_EXTENSIONS = ['.json']
|
||||
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
|
||||
MAX_RECORDS_PER_BATCH = 100
|
||||
DEFAULT_BATCH_SIZE = 50
|
||||
|
||||
|
||||
def resolve_safe_path(
|
||||
path: str, allowed_root: Optional[str] = None
|
||||
) -> Path:
|
||||
if allowed_root is None:
|
||||
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
|
||||
allowed_root = Path(allowed_root).resolve()
|
||||
target_path = (
|
||||
Path(path).resolve()
|
||||
if Path(path).is_absolute()
|
||||
else (Path.cwd() / path).resolve()
|
||||
)
|
||||
try:
|
||||
target_path.relative_to(allowed_root)
|
||||
return target_path
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"路径超出允许范围:{path}\n"
|
||||
f"目标路径:{target_path}\n"
|
||||
f"允许根目录:{allowed_root}\n"
|
||||
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
|
||||
)
|
||||
|
||||
|
||||
def validate_resource_id(resource_id: str) -> bool:
|
||||
return bool(
|
||||
resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())
|
||||
)
|
||||
|
||||
|
||||
def validate_file_extension(
|
||||
filename: str, allowed_extensions: list
|
||||
) -> bool:
|
||||
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
|
||||
|
||||
|
||||
def safe_csv_load(
|
||||
file_path: Path, max_size: int = MAX_FILE_SIZE
|
||||
) -> List[RecordDict]:
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > max_size:
|
||||
raise ValueError(
|
||||
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
|
||||
)
|
||||
with open(file_path, 'r', encoding='utf-8', newline='') as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def safe_json_load(
|
||||
file_path: Path, max_size: int = MAX_FILE_SIZE
|
||||
) -> JsonData:
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > max_size:
|
||||
raise ValueError(
|
||||
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
|
||||
)
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def sanitize_record_value(
|
||||
value: Any,
|
||||
) -> Optional[Union[str, int, float, bool, list, dict]]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (bool, int, float, list, dict)):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value.strip():
|
||||
return None
|
||||
|
||||
value = value.strip()
|
||||
if value.lower() == 'true':
|
||||
return True
|
||||
if value.lower() == 'false':
|
||||
return False
|
||||
|
||||
try:
|
||||
if '.' in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def normalize_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if 'cells' in record and isinstance(record['cells'], dict):
|
||||
cells = record['cells']
|
||||
else:
|
||||
cells = record
|
||||
normalized = {}
|
||||
for key, value in cells.items():
|
||||
sanitized = sanitize_record_value(value)
|
||||
if sanitized is not None:
|
||||
normalized[key] = sanitized
|
||||
return {'cells': normalized}
|
||||
|
||||
|
||||
def validate_record(
|
||||
record: Dict[str, Any], headers: List[str]
|
||||
) -> Tuple[bool, str]:
|
||||
if not isinstance(record, dict):
|
||||
return False, '记录必须是对象'
|
||||
normalized = normalize_record(record)
|
||||
cells = normalized.get('cells', {})
|
||||
if not cells or not isinstance(cells, dict):
|
||||
return False, '记录必须包含非空 cells 对象'
|
||||
return True, ''
|
||||
|
||||
|
||||
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
|
||||
if not args:
|
||||
print('错误:空命令')
|
||||
return None
|
||||
cmd = ['dws'] + args
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}")
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"无法解析响应:{result.stdout[:200]}...")
|
||||
print(f"JSON 解析错误:{e}")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
print('错误:命令执行超时(120 秒)')
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
print('错误:未找到 dws 命令,请确认已安装')
|
||||
return None
|
||||
|
||||
|
||||
def import_from_csv(
|
||||
base_id: str, table_id: str, csv_file: str,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
) -> bool:
|
||||
try:
|
||||
safe_path = resolve_safe_path(csv_file)
|
||||
except ValueError as e:
|
||||
print(f"路径验证失败:{e}")
|
||||
return False
|
||||
|
||||
if not validate_file_extension(csv_file, ALLOWED_CSV_EXTENSIONS):
|
||||
print(f"错误:只允许 {', '.join(ALLOWED_CSV_EXTENSIONS)} 文件")
|
||||
return False
|
||||
if not safe_path.exists():
|
||||
print(f"错误:文件不存在:{safe_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
rows = safe_csv_load(safe_path)
|
||||
except ValueError as e:
|
||||
print(f"错误:{e}")
|
||||
return False
|
||||
except csv.Error as e:
|
||||
print(f"错误:CSV 格式无效:{e}")
|
||||
return False
|
||||
|
||||
if not rows:
|
||||
print('错误:CSV 文件为空或没有有效数据行')
|
||||
return False
|
||||
|
||||
records = [
|
||||
normalize_record(row)
|
||||
for row in rows
|
||||
if normalize_record(row)['cells']
|
||||
]
|
||||
return import_records(base_id, table_id, records, batch_size)
|
||||
|
||||
|
||||
def import_from_json(
|
||||
base_id: str, table_id: str, json_file: str,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
) -> bool:
|
||||
try:
|
||||
safe_path = resolve_safe_path(json_file)
|
||||
except ValueError as e:
|
||||
print(f"路径验证失败:{e}")
|
||||
return False
|
||||
|
||||
if not validate_file_extension(json_file, ALLOWED_JSON_EXTENSIONS):
|
||||
print(f"错误:只允许 {', '.join(ALLOWED_JSON_EXTENSIONS)} 文件")
|
||||
return False
|
||||
if not safe_path.exists():
|
||||
print(f"错误:文件不存在:{safe_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
records = safe_json_load(safe_path)
|
||||
except ValueError as e:
|
||||
print(f"错误:{e}")
|
||||
return False
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"错误:JSON 格式无效:{e}")
|
||||
return False
|
||||
|
||||
if not isinstance(records, list) or not records:
|
||||
print('错误:JSON 文件必须是非空数组')
|
||||
return False
|
||||
|
||||
for i, record in enumerate(records):
|
||||
valid, error = validate_record(record, [])
|
||||
if not valid:
|
||||
print(f"错误:记录 #{i+1} 格式无效:{error}")
|
||||
return False
|
||||
|
||||
return import_records(
|
||||
base_id, table_id,
|
||||
[normalize_record(r) for r in records], batch_size,
|
||||
)
|
||||
|
||||
|
||||
def import_records(
|
||||
base_id: str, table_id: str,
|
||||
records: List[Dict[str, Any]], batch_size: int,
|
||||
) -> bool:
|
||||
if batch_size <= 0:
|
||||
print('错误:batch_size 必须大于 0')
|
||||
return False
|
||||
if batch_size > MAX_RECORDS_PER_BATCH:
|
||||
batch_size = MAX_RECORDS_PER_BATCH
|
||||
|
||||
total_batches = (len(records) + batch_size - 1) // batch_size
|
||||
success = True
|
||||
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = records[i:i + batch_size]
|
||||
batch_num = (i // batch_size) + 1
|
||||
records_json = json.dumps(batch, ensure_ascii=False)
|
||||
result = run_dws([
|
||||
'aitable', 'record', 'create',
|
||||
'--base-id', base_id,
|
||||
'--table-id', table_id,
|
||||
'--records', records_json,
|
||||
'--format', 'json',
|
||||
])
|
||||
if result:
|
||||
print(
|
||||
f"[{batch_num}/{total_batches}] "
|
||||
f"✓ 已提交 {len(batch)} 条记录"
|
||||
)
|
||||
else:
|
||||
print(f"[{batch_num}/{total_batches}] ✗ 导入失败")
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4 or len(sys.argv) > 5:
|
||||
print(__doc__)
|
||||
print('用法示例:')
|
||||
print(
|
||||
' python import_records.py basexxx tablexxx data.csv 50'
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
base_id = sys.argv[1]
|
||||
table_id = sys.argv[2]
|
||||
input_file = sys.argv[3]
|
||||
batch_size = (
|
||||
int(sys.argv[4]) if len(sys.argv) == 5
|
||||
else DEFAULT_BATCH_SIZE
|
||||
)
|
||||
|
||||
if not validate_resource_id(base_id):
|
||||
print('错误:无效的 baseId 格式')
|
||||
sys.exit(1)
|
||||
if not validate_resource_id(table_id):
|
||||
print('错误:无效的 tableId 格式')
|
||||
sys.exit(1)
|
||||
|
||||
if input_file.lower().endswith('.csv'):
|
||||
success = import_from_csv(
|
||||
base_id, table_id, input_file, batch_size
|
||||
)
|
||||
elif input_file.lower().endswith('.json'):
|
||||
success = import_from_json(
|
||||
base_id, table_id, input_file, batch_size
|
||||
)
|
||||
else:
|
||||
print('错误:仅支持 .csv 或 .json 文件')
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查看今天收到的日志列表及详情
|
||||
|
||||
用法:
|
||||
python report_inbox_today.py
|
||||
python report_inbox_today.py --days 3 # 最近 3 天
|
||||
python report_inbox_today.py --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def to_iso(dt: datetime) -> str:
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='查看收到的日志'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--days', type=int, default=1, help='查询天数 (默认 1)'
|
||||
)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
now = datetime.now()
|
||||
start = now - timedelta(days=args.days)
|
||||
start = start.replace(hour=0, minute=0, second=0)
|
||||
|
||||
label = '今天' if args.days == 1 else f'最近 {args.days} 天'
|
||||
print(f'📓 查看{label}收到的日志...\n')
|
||||
|
||||
data = run_dws([
|
||||
'report', 'list',
|
||||
'--start', to_iso(start),
|
||||
'--end', to_iso(now),
|
||||
'--cursor', '0',
|
||||
'--size', '20',
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
return
|
||||
if not data:
|
||||
print('未查到日志')
|
||||
return
|
||||
|
||||
reports = (data if isinstance(data, list)
|
||||
else data.get('result', data.get('reports', [])))
|
||||
if not reports:
|
||||
print(' ✅ 暂无收到的日志')
|
||||
return
|
||||
|
||||
print(f"📓 {label}日志 ({len(reports)} 条)")
|
||||
print('=' * 50)
|
||||
|
||||
for r in reports:
|
||||
rid = r.get('reportId') or r.get('id', '')
|
||||
creator = r.get('creatorName') or r.get('creator', '未知')
|
||||
template = r.get('templateName') or r.get('template', '')
|
||||
create_time = r.get('createTime', '')
|
||||
if isinstance(create_time, (int, float)):
|
||||
create_time = datetime.fromtimestamp(
|
||||
create_time / 1000
|
||||
).strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
print(f"\n 📝 {template or '日志'} - {creator}")
|
||||
print(f" 时间: {create_time}")
|
||||
print(f" ID: {rid}")
|
||||
|
||||
if rid:
|
||||
detail = run_dws([
|
||||
'report', 'detail',
|
||||
'--report-id', rid, '--format', 'json',
|
||||
])
|
||||
if detail and isinstance(detail, dict):
|
||||
contents = detail.get('contents', [])
|
||||
for c in contents[:3]:
|
||||
key = c.get('key') or c.get('title', '')
|
||||
val = c.get('value') or c.get('content', '')
|
||||
if key and val:
|
||||
print(f" {key}: {str(val)[:60]}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 JSON 文件批量创建待办(含优先级、截止时间、执行者)
|
||||
|
||||
用法:
|
||||
python todo_batch_create.py todos.json
|
||||
python todo_batch_create.py todos.json --dry-run
|
||||
|
||||
todos.json 格式:
|
||||
[
|
||||
{"title": "修复线上Bug", "executors": "userId1,userId2", "priority": 40},
|
||||
{"title": "写周报", "executors": "userId1", "due": "2026-03-15"},
|
||||
{"title": "代码评审", "executors": "userId1"},
|
||||
{"title": "每日站会", "executors": "userId1", "due": "2026-03-20",
|
||||
"recurrence": "DTSTART:20260320T020000Z\\nRRULE:FREQ=DAILY;INTERVAL=1"}
|
||||
]
|
||||
|
||||
字段说明:
|
||||
- title: 待办标题 (必填)
|
||||
- executors: 执行者 userId,多人逗号分隔 (必填)
|
||||
- priority: 优先级 10=低/20=普通/30=较高/40=紧急 (可选)
|
||||
- due: 截止日期 YYYY-MM-DD 或毫秒时间戳 (可选)
|
||||
- recurrence: 循环待办规则 (可选,需同时有 due);字符串内需含换行,与 dws --recurrence 一致
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
ALLOWED_PRIORITIES = {10, 20, 30, 40}
|
||||
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return {'dry_run': True}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" ✗ 错误:{result.stderr.strip()}")
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(' ✗ 命令执行超时', file=sys.stderr)
|
||||
return None
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
print(f" ✗ 错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def parse_due(due_value) -> Optional[str]:
|
||||
if not due_value:
|
||||
return None
|
||||
due_str = str(due_value)
|
||||
if due_str.isdigit() and len(due_str) >= 10:
|
||||
return due_str
|
||||
if DATE_PATTERN.match(due_str):
|
||||
dt = datetime.strptime(due_str, '%Y-%m-%d')
|
||||
dt = dt.replace(hour=23, minute=59, second=59)
|
||||
return str(int(dt.timestamp() * 1000))
|
||||
print(f" ⚠ 无法解析截止时间:{due_value},跳过")
|
||||
return None
|
||||
|
||||
|
||||
def validate_todo(item: Dict[str, Any], idx: int) -> bool:
|
||||
if not isinstance(item, dict):
|
||||
print(f" ✗ #{idx+1} 不是有效对象")
|
||||
return False
|
||||
if not item.get('title', '').strip():
|
||||
print(f" ✗ #{idx+1} 缺少 title")
|
||||
return False
|
||||
if not item.get('executors', '').strip():
|
||||
print(f" ✗ #{idx+1} 缺少 executors")
|
||||
return False
|
||||
priority = item.get('priority')
|
||||
if priority is not None and int(priority) not in ALLOWED_PRIORITIES:
|
||||
print(f" ✗ #{idx+1} 无效优先级:{priority}")
|
||||
return False
|
||||
recurrence = item.get('recurrence')
|
||||
if recurrence and not str(recurrence).strip():
|
||||
print(f" ✗ #{idx+1} recurrence 不能为空字符串")
|
||||
return False
|
||||
if recurrence and not item.get('due'):
|
||||
print(f" ✗ #{idx+1} 设置 recurrence 时必须提供 due")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
args = [a for a in sys.argv[1:] if a != '--dry-run']
|
||||
if not args:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
file_path = Path(args[0])
|
||||
if not file_path.exists():
|
||||
print(f"错误:文件不存在:{file_path}")
|
||||
sys.exit(1)
|
||||
if file_path.stat().st_size > MAX_FILE_SIZE:
|
||||
print(f"错误:文件过大 (限制 {MAX_FILE_SIZE // 1024}KB)")
|
||||
sys.exit(1)
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
todos = json.load(f)
|
||||
if not isinstance(todos, list) or not todos:
|
||||
print('错误:JSON 文件必须是非空数组')
|
||||
sys.exit(1)
|
||||
|
||||
for i, item in enumerate(todos):
|
||||
if not validate_todo(item, i):
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📋 准备创建 {len(todos)} 条待办\n")
|
||||
success, fail = 0, 0
|
||||
for i, item in enumerate(todos):
|
||||
title = item['title'].strip()
|
||||
cmd_args = [
|
||||
'todo', 'task', 'create',
|
||||
'--title', title,
|
||||
'--executors', item['executors'].strip(),
|
||||
'--format', 'json',
|
||||
]
|
||||
priority = item.get('priority')
|
||||
if priority is not None:
|
||||
cmd_args.extend(['--priority', str(int(priority))])
|
||||
due = parse_due(item.get('due'))
|
||||
if due:
|
||||
cmd_args.extend(['--due', due])
|
||||
recurrence = item.get('recurrence')
|
||||
if recurrence:
|
||||
rr = str(recurrence).replace('\\n', '\n')
|
||||
cmd_args.extend(['--recurrence', rr])
|
||||
|
||||
result = run_dws(cmd_args, dry_run=dry_run)
|
||||
if result:
|
||||
print(f" ✓ [{i+1}/{len(todos)}] {title}")
|
||||
success += 1
|
||||
else:
|
||||
print(f" ✗ [{i+1}/{len(todos)}] {title}")
|
||||
fail += 1
|
||||
|
||||
print(f"\n完成: 成功 {success}, 失败 {fail}")
|
||||
sys.exit(0 if fail == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查询今天/明天/本周未完成的待办并汇总输出
|
||||
|
||||
用法:
|
||||
python todo_daily_summary.py # 默认查今天
|
||||
python todo_daily_summary.py today # 今天的待办
|
||||
python todo_daily_summary.py tomorrow # 明天的待办
|
||||
python todo_daily_summary.py week # 本周的待办
|
||||
python todo_daily_summary.py --dry-run # 仅显示将执行的命令
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
|
||||
PAGE_SIZE = 50
|
||||
MAX_PAGES = 10
|
||||
|
||||
|
||||
def run_dws(args: List[str], dry_run: bool = False) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except subprocess.TimeoutExpired:
|
||||
print('错误:命令执行超时', file=sys.stderr)
|
||||
return None
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_date_range(scope: str):
|
||||
now = datetime.now()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if scope == 'today':
|
||||
return today_start, today_start + timedelta(days=1)
|
||||
elif scope == 'tomorrow':
|
||||
tmr = today_start + timedelta(days=1)
|
||||
return tmr, tmr + timedelta(days=1)
|
||||
elif scope == 'week':
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
return week_start, week_start + timedelta(days=7)
|
||||
return today_start, today_start + timedelta(days=1)
|
||||
|
||||
|
||||
def fetch_all_todos(
|
||||
dry_run: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
all_todos: List[Dict[str, Any]] = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
data = run_dws([
|
||||
'todo', 'task', 'list',
|
||||
'--page', str(page),
|
||||
'--size', str(PAGE_SIZE),
|
||||
'--status', 'false',
|
||||
'--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run:
|
||||
return []
|
||||
if not data:
|
||||
break
|
||||
items = data if isinstance(data, list) else data.get(
|
||||
'result', data.get('todoCards', [])
|
||||
)
|
||||
if not items or not isinstance(items, list):
|
||||
break
|
||||
all_todos.extend(items)
|
||||
if len(items) < PAGE_SIZE:
|
||||
break
|
||||
return all_todos
|
||||
|
||||
|
||||
def format_priority(p) -> str:
|
||||
try:
|
||||
return PRIORITY_MAP.get(int(p), str(p))
|
||||
except (ValueError, TypeError):
|
||||
return '普通'
|
||||
|
||||
|
||||
def format_due(due_ms) -> str:
|
||||
if not due_ms:
|
||||
return '无截止时间'
|
||||
try:
|
||||
dt = datetime.fromtimestamp(int(due_ms) / 1000)
|
||||
return dt.strftime('%Y-%m-%d %H:%M')
|
||||
except (ValueError, TypeError, OSError):
|
||||
return str(due_ms)
|
||||
|
||||
|
||||
def filter_by_due(
|
||||
todos: List[Dict[str, Any]], start: datetime, end: datetime,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
end_ms = int(end.timestamp() * 1000)
|
||||
result = []
|
||||
for t in todos:
|
||||
due = t.get('dueTime') or t.get('due')
|
||||
if not due:
|
||||
result.append(t)
|
||||
continue
|
||||
try:
|
||||
due_val = int(due)
|
||||
if start_ms <= due_val < end_ms:
|
||||
result.append(t)
|
||||
except (ValueError, TypeError):
|
||||
result.append(t)
|
||||
return result
|
||||
|
||||
|
||||
def print_summary(
|
||||
todos: List[Dict[str, Any]], scope: str,
|
||||
start: datetime, end: datetime,
|
||||
):
|
||||
scope_label = {
|
||||
'today': '今天', 'tomorrow': '明天', 'week': '本周',
|
||||
}.get(scope, scope)
|
||||
print(f"\n📋 {scope_label}未完成待办 "
|
||||
f"({start.strftime('%m-%d')} ~ {end.strftime('%m-%d')})")
|
||||
print('=' * 50)
|
||||
if not todos:
|
||||
print(' ✅ 暂无待办,轻松一下!')
|
||||
return
|
||||
urgent = [t for t in todos if format_priority(
|
||||
t.get('priority')) == '紧急']
|
||||
if urgent:
|
||||
print(f"\n🔴 紧急 ({len(urgent)} 条)")
|
||||
for t in urgent:
|
||||
title = t.get('subject') or t.get('title', '无标题')
|
||||
print(f" • {title} ⏰ {format_due(t.get('dueTime'))}")
|
||||
normal = [t for t in todos if t not in urgent]
|
||||
if normal:
|
||||
print(f"\n📌 其他 ({len(normal)} 条)")
|
||||
for t in normal:
|
||||
title = t.get('subject') or t.get('title', '无标题')
|
||||
pri = format_priority(t.get('priority'))
|
||||
print(f" • [{pri}] {title} ⏰ {format_due(t.get('dueTime'))}")
|
||||
print(f"\n合计: {len(todos)} 条待办")
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
args = [a for a in sys.argv[1:] if a != '--dry-run']
|
||||
scope = args[0] if args else 'today'
|
||||
if scope not in ('today', 'tomorrow', 'week'):
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
start, end = get_date_range(scope)
|
||||
todos = fetch_all_todos(dry_run=dry_run)
|
||||
if dry_run:
|
||||
return
|
||||
filtered = filter_by_due(todos, start, end)
|
||||
print_summary(filtered, scope, start, end)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
扫描已过截止时间但未完成的待办,输出逾期清单
|
||||
|
||||
用法:
|
||||
python todo_overdue_check.py
|
||||
python todo_overdue_check.py --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
PAGE_SIZE = 50
|
||||
MAX_PAGES = 10
|
||||
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_all_undone(dry_run: bool = False) -> List[Dict[str, Any]]:
|
||||
all_todos: List[Dict[str, Any]] = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
data = run_dws([
|
||||
'todo', 'task', 'list',
|
||||
'--page', str(page), '--size', str(PAGE_SIZE),
|
||||
'--status', 'false', '--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run or not data:
|
||||
break
|
||||
items = (data if isinstance(data, list)
|
||||
else data.get('result', data.get('todoCards', [])))
|
||||
if not items or not isinstance(items, list):
|
||||
break
|
||||
all_todos.extend(items)
|
||||
if len(items) < PAGE_SIZE:
|
||||
break
|
||||
return all_todos
|
||||
|
||||
|
||||
def find_overdue(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
now_ms = int(datetime.now().timestamp() * 1000)
|
||||
overdue = []
|
||||
for t in todos:
|
||||
due = t.get('dueTime') or t.get('due')
|
||||
if not due:
|
||||
continue
|
||||
try:
|
||||
if int(due) < now_ms:
|
||||
overdue.append(t)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return overdue
|
||||
|
||||
|
||||
def days_overdue(due_ms) -> int:
|
||||
now = datetime.now()
|
||||
try:
|
||||
due_dt = datetime.fromtimestamp(int(due_ms) / 1000)
|
||||
return max(0, (now - due_dt).days)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
todos = fetch_all_undone(dry_run=dry_run)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
overdue = find_overdue(todos)
|
||||
overdue.sort(
|
||||
key=lambda t: int(t.get('dueTime') or t.get('due', 0))
|
||||
)
|
||||
|
||||
print(f"\n⏰ 逾期待办检查 ({datetime.now().strftime('%Y-%m-%d %H:%M')})")
|
||||
print('=' * 50)
|
||||
|
||||
if not overdue:
|
||||
print(' ✅ 没有逾期待办,继续保持!')
|
||||
return
|
||||
|
||||
for t in overdue:
|
||||
title = t.get('subject') or t.get('title', '无标题')
|
||||
due = t.get('dueTime') or t.get('due')
|
||||
days = days_overdue(due)
|
||||
pri = PRIORITY_MAP.get(
|
||||
int(t.get('priority', 20)), '普通'
|
||||
)
|
||||
due_str = datetime.fromtimestamp(
|
||||
int(due) / 1000
|
||||
).strftime('%Y-%m-%d')
|
||||
print(f" 🔴 [{pri}] {title}")
|
||||
print(f" 截止: {due_str} 逾期: {days} 天")
|
||||
|
||||
print(f"\n合计: {len(overdue)} 条逾期待办")
|
||||
sys.exit(1 if overdue else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
上传附件到钉钉 AI 表格 attachment 字段
|
||||
|
||||
完整流程(内部自动执行 3 步):
|
||||
1. dws aitable attachment upload → 获取 uploadUrl + fileToken
|
||||
2. HTTP PUT 上传文件到 OSS
|
||||
3. 返回 fileToken,可直接用于 record create/update
|
||||
|
||||
用法:
|
||||
python upload_attachment.py <baseId> <filePath>
|
||||
|
||||
输出 (JSON):
|
||||
{ "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
|
||||
|
||||
然后在 record create/update 中使用:
|
||||
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
|
||||
def validate_resource_id(resource_id: str) -> bool:
|
||||
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
|
||||
|
||||
|
||||
def detect_mime_type(file_path: Path) -> str:
|
||||
"""根据文件扩展名推断 MIME type。"""
|
||||
mime_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return mime_type or 'application/octet-stream'
|
||||
|
||||
|
||||
def run_dws(args: list) -> Optional[Dict[str, Any]]:
|
||||
"""调用 dws 命令并返回解析后的 JSON 结果。"""
|
||||
cmd = ['dws'] + args
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:dws 命令失败: {result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
print(f"错误:无法解析 dws 响应: {result.stdout[:300]}", file=sys.stderr)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
print('错误:dws 命令超时(60 秒)', file=sys.stderr)
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
print('错误:未找到 dws 命令,请确认已安装并在 PATH 中', file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool:
|
||||
"""通过 HTTP PUT 上传文件到 OSS。"""
|
||||
file_data = file_path.read_bytes()
|
||||
req = Request(upload_url, data=file_data, method='PUT')
|
||||
req.add_header('Content-Type', mime_type)
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr)
|
||||
return False
|
||||
except HTTPError as e:
|
||||
print(f"错误:OSS 上传 HTTP 错误 {e.code}: {e.reason}", file=sys.stderr)
|
||||
return False
|
||||
except URLError as e:
|
||||
print(f"错误:OSS 上传网络错误: {e.reason}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def upload_attachment(base_id: str, file_path_str: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
执行完整的附件上传流程:
|
||||
1. prepare_attachment_upload → uploadUrl + fileToken
|
||||
2. PUT 文件到 OSS
|
||||
3. 返回 fileToken 信息
|
||||
"""
|
||||
# 验证文件
|
||||
file_path = Path(file_path_str).resolve()
|
||||
if not file_path.exists():
|
||||
print(f"错误:文件不存在: {file_path}", file=sys.stderr)
|
||||
return None
|
||||
if not file_path.is_file():
|
||||
print(f"错误:不是文件: {file_path}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size <= 0:
|
||||
print("错误:文件为空", file=sys.stderr)
|
||||
return None
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
print(f"错误:文件过大 ({file_size:,} 字节,限制 {MAX_FILE_SIZE:,} 字节)", file=sys.stderr)
|
||||
return None
|
||||
|
||||
file_name = file_path.name
|
||||
mime_type = detect_mime_type(file_path)
|
||||
|
||||
# 步骤 1: prepare_attachment_upload
|
||||
print(f"步骤 1/3: 准备上传 {file_name} ({file_size:,} 字节, {mime_type})...", file=sys.stderr)
|
||||
dws_args = [
|
||||
'aitable', 'attachment', 'upload',
|
||||
'--base-id', base_id,
|
||||
'--file-name', file_name,
|
||||
'--size', str(file_size),
|
||||
'--mime-type', mime_type,
|
||||
'--format', 'json',
|
||||
]
|
||||
result = run_dws(dws_args)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
status = result.get('status', '')
|
||||
if status != 'success':
|
||||
error = result.get('error', {})
|
||||
print(f"错误:准备上传失败: {error.get('message', json.dumps(error, ensure_ascii=False))}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
data = result.get('data', {})
|
||||
upload_url = data.get('uploadUrl', '')
|
||||
file_token = data.get('fileToken', '')
|
||||
|
||||
if not upload_url or not file_token:
|
||||
print(f"错误:返回数据缺少 uploadUrl 或 fileToken: {json.dumps(data, ensure_ascii=False)}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# 步骤 2: PUT 文件到 OSS
|
||||
print(f"步骤 2/3: 上传文件到 OSS...", file=sys.stderr)
|
||||
if not upload_to_oss(upload_url, file_path, mime_type):
|
||||
return None
|
||||
|
||||
# 步骤 3: 返回 fileToken
|
||||
print(f"步骤 3/3: 上传完成!", file=sys.stderr)
|
||||
output = {
|
||||
"fileToken": file_token,
|
||||
"fileName": file_name,
|
||||
"size": file_size,
|
||||
"mimeType": mime_type,
|
||||
}
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
print('用法:')
|
||||
print(' python upload_attachment.py <baseId> <filePath>')
|
||||
print()
|
||||
print('示例:')
|
||||
print(' python upload_attachment.py G1DKw2zgV2bEk6PMSBooNxlEVB5r9YAn ./report.pdf')
|
||||
print()
|
||||
print('然后在 record create 中使用返回的 fileToken:')
|
||||
print(' dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \\')
|
||||
print(' --records \'[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]\' --format json')
|
||||
sys.exit(1)
|
||||
|
||||
base_id = sys.argv[1]
|
||||
file_path = sys.argv[2]
|
||||
|
||||
if not validate_resource_id(base_id):
|
||||
print('错误:无效的 baseId 格式', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = upload_attachment(base_id, file_path)
|
||||
if result is None:
|
||||
sys.exit(1)
|
||||
|
||||
# 正常输出到 stdout(JSON 格式,方便解析)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user