ta-outlook-tasks-template
readme
md
# ta-outlook-tasks-template
## 概要
Markdown Template をもとに、Outlook Task を追加するツール
1. 変数を Markdown ファイル内 `- <key> : <value>` 形式で定義
2. タスクを `- [ ] ` 行として定義。その際、定義した変数を使用可能
3. 指定した Outlook フォルダ名を作成する
## コマンド
```
ta-outlook-tasks-template
```
## コマンド引数
- `TEMPLATE_FILE_NAME`
- テンプレートファイル名
- 必須。ファイル存在しない場合はエラーとして処理をやめる
- `--reminder-default-time`
- 時刻指定なし時の ReminderTime
- デフォルト値 `07:00`
## Markdown Template フォーマット
### Markdown フォーマット (テンプレート固有)
- 以下 2 つのブロックで構成される
- `## Info` : 変数定義(なくても処理続行)
- `## Tasks` : 必須(なければエラー)
- ブロックとは
- `## Info`, `## Tasks` 行の次から、次のブロック(`#` の数によらず)かファイル末尾、の手前まで
- ほかのブロックは無視する
- `## Info` ブロック詳細
- `- <key> : <value-start> - <value-end>`
- `<value-start>`, `<value-end>` が 日付+時刻 形式の場合のみ、範囲を `${key-start}`, `${key-end}` として保持とする
- 具体的には `yyyy/MM/dd` と `yyyy/MM/dd HH:mm` 形式で、`MM`, `dd`, `HH` は 1 文字も許容
- それ以外は `- <key> : <value>` としてパースし、` - ` 部分を含む全体を `<value>` として保持
- `- <key> : <value>`
- `<value>` 部分を `${key}` として保持する
- 区切り文字前後の空白について
- `:` の前後の空白は 0 個以上で上限なし
- `-` の前後の空白は 1 個以上で上限なし
- key, value それぞれ、trim して保持
- その他の記載
- 上記にマッチしない行は無視して処理続行
- `## Tasks` ブロック詳細
- `## Info` ブロックで 定義したキーが `key-name` だとして、`${key-name}` のように変数展開して参照可能
- 処理対象も変数展開も `Subject 行` とそれに続く `Body` のみを対象とし、それ以外の行は無視
- 変数展開の特殊仕様
- `${key-name}` が日付の場合、以下の日付計算を可能とする
- `${key-name - 2 weekdays}` : 土日を Skip した日付計算をする。祝日は考慮しない
- `${key-name + 1 day}` : 実日数で計算
- 日付を変更した場合、時刻部分は変更前を保持
- week, month 等は対応なし
- 表記ブレの許容
- 各キーワード間の空白は 1 個以上とする
- `*day`, `*days` の表記ブレは許容
- 入力チェック
- 処理の最初に全件チェックし、エラーがあった場合はエラー内容を出力し、更新を行わず終了
- 定義されていない変数名はエラー
- Info 内で同じ変数が複数現れた場合はエラー
- `Dev-start` を明示した場合と `Dev : start - end` 形式で暗黙的に定義された `Dev-start` での重複もエラーとする
```md
## Info
- Phase : Phase-1.5
- Dev : yyyy/MM/dd - yyyy/MM/dd
- UAT : yyyy/MM/dd - yyyy/MM/dd
- Release : yyyy/MM/dd
## Tasks
- [ ] [${Phase}] XXXXXX (DueDate: ${Dev-end - 2 weekdays})
- [ ] [${Phase}] YYYYYY (DueDate: ${Release})
```
### Markdown フォーマット (一般)
#### 方針
ta-outlook-tasks-sync (転記済のため参照不要) での仕様をベースとし、以下を削った。
- `EntryID` : 本スクリプトでは必ず新規登録となるため
- Markdown 出力時の仕様 : 本スクリプトではパースしかしないため
#### Subject 行
- DueDate + ReminderTime あり
- `- [ ] ${Subject} (DueDate: ${yyyy/MM/dd HH:mm})`
- DueDate あり
- `- [ ] ${Subject} (DueDate: ${yyyy/MM/dd})`
- DueDate なし
- `- [ ] ${Subject}`
#### DueDate + ReminderTime 記載について
- パース時
- `DueDate` : `yyyy/MM/dd` 部分
- `CalcTime` : `DueDate` + `HH:mm` 部分。指定ない場合は `DueDate` + `reminder-default-time`
- `MM`, `dd`, `HH` は 1文字も許容する(`mm` は 1 文字は許容しない)
- 共通
- Outlook COM 上では未設定日を None または番兵値として扱う
#### Body
- 共通
- Body については インデントされた fenced code block に記載する
- Markdown パース時
- Body 部分のインデントは、1 行目の空白文字数分の空白を、2 行目以降も削る
- 2 行目以降の行頭空白が 1 行目より短い場合も、空白部分のみ削る
````
- [ ] ${Subject} ...
```
Body 1 行目
Body 2 行目
...
```
````
## Outlook タスク追加処理
- 処理内容
- `## Tasks` ブロックで定義されたタスクを、Outlook に登録する
- `TEMPLATE_FILE_NAME` の拡張子を除いたものをタスク登録先の Outlook フォルダ名とする
- olFolderTasks 直下にフォルダ作成
- 子孫階層は指定不可
- 対象フォルダの扱い
- 存在しない場合
- 新規作成
- すでに存在する場合
- フォルダ内のタスクを Complete も含めて すべて削除してから進めるか y/n で問い合わせる
- y : 対象フォルダ内のタスクをすべて削除後、処理続行
- n : 更新を行わず処理終了
- それ以外 : 再度問い合わせ
## 前提
- スクリプトは Python を使用する
- pywin32 が使用可能であること
- `pip install pywin32`
- Outlook がインストール済で、アカウントも設定済であること
- エラーメッセージやヘルプは 英語のみ
- 時刻は 実行環境のロケールに従う
## インストールする場合
初回のみ以下を実行。
```ps1
pip install -e .
```
pyproject.toml に従って wrapper の実行ファイルが作られて、以下のように呼び出し可能となる。
(editable install (-e) ではスクリプトを参照する wrapper が作られるため、都度ビルドしなおしは不要だが、所定ディレクトリに exe は配置される)
```ps1
ta-outlook-tasks-template
```
インストールしないで使用するには、以下のように直接スクリプトを実行すればよい
```ps
python ta_outlook_tasks_template.py TEMPLATE_FILE_NAME
```
scripts
md
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from pathlib import Path
OL_FOLDER_TASKS = 13
OL_TASK_ITEM = 3
OL_OBJECT_CLASS_TASK = 48
OUTLOOK_EMPTY_DATE_TEXT = "4501/01/01"
DEFAULT_REMINDER_TIME = "07:00"
class TemplateError(Exception):
pass
@dataclass(frozen=True)
class MarkdownTask:
checked: bool
subject: str
due_date: date | None
reminder_time: time | None
body: str | None = None
@dataclass(frozen=True)
class MarkdownDateTime:
value: datetime
has_time: bool
@dataclass(frozen=True)
class TemplateVariable:
raw: str
parsed: MarkdownDateTime | None
@dataclass(frozen=True)
class MarkdownBlock:
start_line: int
end_line: int
TASK_LINE_RE = re.compile(
r"""
^-\s\[(?P<mark>[ xX])\]\s+
(?P<body>.*?)
(?:\s+\(DueDate:\s+
(?P<date>\d{4}/\d{1,2}/\d{1,2})
(?:\s+(?P<hour>\d{1,2}):(?P<minute>\d{2}))?
\))?
\s*$
""",
re.VERBOSE,
)
INFO_LINE_RE = re.compile(r"^-\s*(?P<key>[^:]+?)\s*:\s*(?P<value>.*?)\s*$")
RANGE_SEPARATOR_RE = re.compile(r"\s+-\s+")
VARIABLE_RE = re.compile(
r"""
\$\{
\s*(?P<key>[A-Za-z0-9_-]+)
(?:
\s+(?P<op>[+-])
\s+(?P<count>\d+)
\s+(?P<unit>weekdays?|days?)
)?
\s*
\}
""",
re.VERBOSE,
)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="ta-outlook-tasks-template",
description="Create Outlook tasks from a Markdown template.",
)
parser.add_argument("template_file_name", help="Markdown template file.")
parser.add_argument(
"--reminder-default-time",
default=DEFAULT_REMINDER_TIME,
help=f"Default ReminderTime when DueDate has no time. Default: {DEFAULT_REMINDER_TIME}",
)
return parser.parse_args(argv)
def parse_hhmm(value: str) -> time:
match = re.fullmatch(r"(\d{1,2}):(\d{2})", value)
if not match:
raise TemplateError(f"Invalid time: {value}")
hour = int(match.group(1))
minute = int(match.group(2))
try:
return time(hour, minute)
except ValueError as exc:
raise TemplateError(f"Invalid time: {value}") from exc
def parse_markdown_date(value: str) -> date:
parts = value.split("/")
if len(parts) != 3:
raise TemplateError(f"Invalid date: {value}")
year, month, day = (int(part) for part in parts)
try:
return date(year, month, day)
except ValueError as exc:
raise TemplateError(f"Invalid date: {value}") from exc
def try_parse_markdown_datetime(value: str) -> MarkdownDateTime | None:
match = re.fullmatch(
r"(?P<date>\d{4}/\d{1,2}/\d{1,2})(?:\s+(?P<hour>\d{1,2}):(?P<minute>\d{2}))?",
value.strip(),
)
if not match:
return None
parsed_date = parse_markdown_date(match.group("date"))
if match.group("hour") is None:
return MarkdownDateTime(datetime.combine(parsed_date, time.min), False)
parsed_time = parse_hhmm(f"{match.group('hour')}:{match.group('minute')}")
return MarkdownDateTime(datetime.combine(parsed_date, parsed_time), True)
def format_date(value: date) -> str:
return f"{value.year:04d}/{value.month:02d}/{value.day:02d}"
def format_time(value: time) -> str:
return f"{value.hour:02d}:{value.minute:02d}"
def format_markdown_datetime(value: MarkdownDateTime) -> str:
if value.has_time:
return f"{format_date(value.value.date())} {format_time(value.value.time())}"
return format_date(value.value.date())
def find_block(lines: list[str], heading: str) -> MarkdownBlock | None:
start = None
for index, line in enumerate(lines):
if line.rstrip("\r\n") == heading:
start = index
break
if start is None:
return None
end = len(lines)
for index in range(start + 1, len(lines)):
if re.match(r"^#+\s+", lines[index]):
end = index
break
return MarkdownBlock(start, end)
def add_variable(variables: dict[str, TemplateVariable], key: str, value: str) -> None:
if key in variables:
raise TemplateError(f"Duplicate variable: {key}")
parsed = try_parse_markdown_datetime(value)
variables[key] = TemplateVariable(raw=value, parsed=parsed)
def parse_info_block(markdown: str) -> dict[str, TemplateVariable]:
lines = markdown.splitlines(keepends=True)
block = find_block(lines, "## Info")
if block is None:
return {}
variables: dict[str, TemplateVariable] = {}
for index in range(block.start_line + 1, block.end_line):
raw_line = lines[index].rstrip("\r\n")
if raw_line.strip() == "":
continue
match = INFO_LINE_RE.fullmatch(raw_line)
if not match:
continue
key = match.group("key").strip()
value = match.group("value").strip()
range_parts = RANGE_SEPARATOR_RE.split(value, maxsplit=1)
if len(range_parts) == 2:
start_value = range_parts[0].strip()
end_value = range_parts[1].strip()
start_dt = try_parse_markdown_datetime(start_value)
end_dt = try_parse_markdown_datetime(end_value)
if start_dt is not None and end_dt is not None:
add_variable(variables, f"{key}-start", start_value)
add_variable(variables, f"{key}-end", end_value)
continue
add_variable(variables, key, value)
return variables
def add_days(value: MarkdownDateTime, count: int) -> MarkdownDateTime:
return MarkdownDateTime(value.value + timedelta(days=count), value.has_time)
def add_weekdays(value: MarkdownDateTime, count: int) -> MarkdownDateTime:
step = 1 if count >= 0 else -1
remaining = abs(count)
current = value.value
while remaining:
current += timedelta(days=step)
if current.weekday() < 5:
remaining -= 1
return MarkdownDateTime(current, value.has_time)
def calculate_datetime(value: MarkdownDateTime, op: str, count: int, unit: str) -> MarkdownDateTime:
signed_count = count if op == "+" else -count
if unit in ("day", "days"):
return add_days(value, signed_count)
if unit in ("weekday", "weekdays"):
return add_weekdays(value, signed_count)
raise TemplateError(f"Unsupported date unit: {unit}")
def expand_variables_in_text(text: str, variables: dict[str, TemplateVariable]) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group("key")
variable = variables.get(key)
if variable is None:
raise TemplateError(f"Undefined variable: {key}")
op = match.group("op")
if op is None:
return variable.raw
if variable.parsed is None:
raise TemplateError(f"Date calculation requires a date variable: {key}")
calculated = calculate_datetime(variable.parsed, op, int(match.group("count")), match.group("unit"))
return format_markdown_datetime(calculated)
return VARIABLE_RE.sub(replace, text)
def count_leading_whitespace(value: str) -> int:
return len(value) - len(value.lstrip(" \t"))
def remove_body_indentation(line: str, indent_width: int) -> str:
content = line.rstrip("\r\n")
leading_width = count_leading_whitespace(content)
return content[min(leading_width, indent_width) :]
def parse_body_block(lines: list[str], start_index: int, block_end: int) -> tuple[str | None, int]:
if start_index >= block_end or lines[start_index].strip() == "":
return None, start_index
opening = re.fullmatch(r"(?P<indent>[ \t]+)(?P<fence>`{3,})\s*", lines[start_index].rstrip("\r\n"))
if not opening:
if lines[start_index].startswith((" ", "\t")):
raise TemplateError(f"Invalid body block at line {start_index + 1}: {lines[start_index].rstrip()}")
return None, start_index
indent = opening.group("indent")
fence = opening.group("fence")
content_lines: list[str] = []
index = start_index + 1
while index < block_end:
line = lines[index]
closing = re.fullmatch(rf"{re.escape(indent)}`{{{len(fence)},}}\s*", line.rstrip("\r\n"))
if closing:
return "\n".join(content_lines), index + 1
content_lines.append(remove_body_indentation(line, len(indent)))
index += 1
raise TemplateError(f"Unclosed body block at line {start_index + 1}.")
def parse_task_line(line: str, line_number: int) -> MarkdownTask:
if line.startswith((" ", "\t")):
raise TemplateError(f"Indented task is not allowed at line {line_number}.")
match = TASK_LINE_RE.fullmatch(line.rstrip("\r\n"))
if not match:
raise TemplateError(f"Invalid task line at line {line_number}: {line.rstrip()}")
subject = match.group("body").strip()
if not subject:
raise TemplateError(f"Subject is empty at line {line_number}.")
if "DueDate:" in subject or "<!--" in subject or "-->" in subject:
raise TemplateError(f"Reserved key appears in subject at line {line_number}.")
due_date_value = match.group("date")
due_date = parse_markdown_date(due_date_value) if due_date_value else None
reminder_time = None
if match.group("hour") is not None:
reminder_time = parse_hhmm(f"{match.group('hour')}:{match.group('minute')}")
return MarkdownTask(
checked=match.group("mark").lower() == "x",
subject=subject,
due_date=due_date,
reminder_time=reminder_time,
)
def parse_tasks_block(markdown: str) -> list[MarkdownTask]:
lines = markdown.splitlines(keepends=True)
block = find_block(lines, "## Tasks")
if block is None:
raise TemplateError("Missing ## Tasks block.")
tasks: list[MarkdownTask] = []
index = block.start_line + 1
while index < block.end_line:
raw_line = lines[index]
if raw_line.strip() == "":
index += 1
continue
if not raw_line.startswith("- ["):
index += 1
continue
task = parse_task_line(raw_line, index + 1)
body, next_index = parse_body_block(lines, index + 1, block.end_line)
tasks.append(
MarkdownTask(
checked=task.checked,
subject=task.subject,
due_date=task.due_date,
reminder_time=task.reminder_time,
body=body,
)
)
index = next_index
return tasks
def expand_template(markdown: str) -> str:
variables = parse_info_block(markdown)
lines = markdown.splitlines(keepends=True)
block = find_block(lines, "## Tasks")
if block is None:
raise TemplateError("Missing ## Tasks block.")
expanded_lines = list(lines)
index = block.start_line + 1
while index < block.end_line:
raw_line = lines[index]
if raw_line.startswith("- ["):
expanded_lines[index] = expand_variables_in_text(raw_line, variables)
_, next_index = parse_body_block(lines, index + 1, block.end_line)
for body_index in range(index + 1, next_index):
expanded_lines[body_index] = expand_variables_in_text(lines[body_index], variables)
index = next_index
continue
index += 1
return "".join(expanded_lines)
def parse_template_tasks(markdown: str) -> list[MarkdownTask]:
expanded = expand_template(markdown)
return parse_tasks_block(expanded)
def read_existing_text(path: Path) -> str:
if not path.exists():
raise TemplateError(f"Template file not found: {path}")
return path.read_text(encoding="utf-8")
def get_outlook_app() -> object:
try:
import win32com.client
except ImportError as exc:
raise TemplateError("pywin32 is required. Install it with: pip install pywin32") from exc
return win32com.client.Dispatch("Outlook.Application")
def get_or_create_tasks_folder(outlook: object, folder_name: str) -> tuple[object, bool]:
namespace = outlook.GetNamespace("MAPI")
root = namespace.GetDefaultFolder(OL_FOLDER_TASKS)
for child in root.Folders:
child_name = child.Name
if child_name == folder_name or strip_folder_suffix(child_name) == folder_name:
ensure_task_folder(child, folder_name)
return child, False
return root.Folders.Add(folder_name, OL_FOLDER_TASKS), True
def strip_folder_suffix(folder_name: str) -> str:
return re.sub(r"\s+\([^()]+\)$", "", folder_name)
def ensure_task_folder(folder: object, folder_name: str) -> None:
default_item_type = getattr(folder, "DefaultItemType", OL_TASK_ITEM)
if default_item_type != OL_TASK_ITEM:
raise TemplateError(
f"Outlook folder exists but is not a task folder: {folder_name}. "
"Delete or rename it, then run this command again."
)
def collect_task_items(folder: object) -> list[object]:
items: list[object] = []
for item in folder.Items:
if getattr(item, "Class", OL_OBJECT_CLASS_TASK) != OL_OBJECT_CLASS_TASK:
continue
items.append(item)
return items
def confirm_clear_folder(folder: object) -> bool:
print(f"Outlook folder already exists: {getattr(folder, 'FolderPath', getattr(folder, 'Name', '(unknown)'))}")
while True:
answer = input("Delete all tasks in this folder and continue? [y/n]: ").strip().lower()
if answer == "y":
return True
if answer == "n":
return False
print("Please enter y or n.")
def clear_tasks(folder: object) -> int:
items = collect_task_items(folder)
for item in items:
item.Delete()
return len(items)
def normalized_reminder_datetime(task: MarkdownTask, default_reminder_time: time) -> datetime | None:
if task.due_date is None:
return None
return datetime.combine(task.due_date, task.reminder_time or default_reminder_time)
def format_outlook_date(value: date) -> str:
return format_date(value)
def format_outlook_datetime(value: datetime) -> str:
return f"{format_date(value.date())} {format_time(value.time())}"
def apply_due_and_reminder(item: object, task: MarkdownTask, default_reminder_time: time) -> None:
if task.due_date is None:
item.DueDate = OUTLOOK_EMPTY_DATE_TEXT
item.ReminderSet = False
return
item.DueDate = format_outlook_date(task.due_date)
item.ReminderSet = True
reminder_time = normalized_reminder_datetime(task, default_reminder_time)
if reminder_time is not None:
item.ReminderTime = format_outlook_datetime(reminder_time)
def create_task(outlook: object, task: MarkdownTask, folder: object, default_reminder_time: time) -> object:
item = outlook.CreateItem(OL_TASK_ITEM)
item.Subject = task.subject
if task.body is not None:
item.Body = task.body
apply_due_and_reminder(item, task, default_reminder_time)
item.Save()
parent = getattr(item, "Parent", None)
parent_path = getattr(parent, "FolderPath", None)
folder_path = getattr(folder, "FolderPath", None)
if parent_path != folder_path:
item = item.Move(folder)
if task.checked:
item.MarkComplete()
item.Save()
return item
def folder_name_from_template_path(path: Path) -> str:
return path.stem
def run_template(template_path: Path, default_reminder_time: time, outlook: object | None = None) -> int:
markdown = read_existing_text(template_path)
tasks = parse_template_tasks(markdown)
outlook_app = outlook or get_outlook_app()
folder_name = folder_name_from_template_path(template_path)
folder, created = get_or_create_tasks_folder(outlook_app, folder_name)
if not created:
if not confirm_clear_folder(folder):
print("Aborted.")
return 0
deleted_count = clear_tasks(folder)
print(f"Deleted {deleted_count} existing task(s).")
for task in tasks:
create_task(outlook_app, task, folder, default_reminder_time)
print(f"Created {len(tasks)} task(s) in folder: {folder_name}")
return 0
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
try:
default_reminder_time = parse_hhmm(args.reminder_default_time)
return run_template(Path(args.template_file_name), default_reminder_time)
except TemplateError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())