EvernoteからObsidianへノートを移行すると、Markdownファイルの作成日時・更新日時が移行作業を行った日時になりやすくなります。ノート本文は移せても、時系列で整理していた過去の記録が扱いにくくなる問題です。
本記事では、EvernoteのENEXからノートのタイトル・作成日時・更新日時をローカルで抽出し、Obsidian側へ次の2段階で反映する方法を紹介します。
- YAMLフロントマターの
created・updatedプロパティへ反映する。 - Windows上のMarkdownファイル自身の作成日時・更新日時へ反映する。
記事末尾に、追加ライブラリなしで動く3本のPythonスクリプトを全文掲載しています。
対象環境: Windows、Python 3.9以降、EvernoteのENEXエクスポート、Obsidian Vault。Windows以外でもCSV抽出とYAML反映は使えますが、実ファイル日時の変更はWindows専用です。
この記事で解決すること
| 課題 | 対応 |
|---|---|
| Evernoteノートの元の作成日時を一覧化したい | ENEXの各noteにあるcreatedをCSVへ抽出する |
| Evernoteノートの更新日時も保持したい | 同じくupdatedをCSVへ抽出する |
| Obsidian上で日時プロパティを使いたい | YAMLのcreated・updatedへ反映する |
| Obsidianの「ファイル作成日時」「ファイル更新日時」ソートを使いたい | Windows実ファイル日時もEvernote日時へ反映する |
| 同名ノートで誤更新したくない | タイトルが1対1で一致するノートだけを更新する |
| ノート本文を外部に送信したくない | すべてローカルで処理し、外部通信しない |
YAMLの日時と実ファイル日時は別物
Obsidianのノート先頭には、YAMLフロントマターとして任意のプロパティを保存できます。
---
created: 2021-04-03 10:15
updated: 2024-11-20 18:30
---
# ノート本文
このcreated・updatedはノート内のメタデータです。一方で、Windowsが保持するファイルの作成日時・更新日時は別の属性です。
| 並べ替え・表示の方法 | 使われる日時 |
|---|---|
Obsidianのプロパティ、Bases、Dataviewなどでcreated・updatedを指定 |
YAMLフロントマターの日時 |
| Obsidianのファイル作成日時・更新日時でソート | Windows実ファイル日時 |
| Windowsエクスプローラー | Windows実ファイル日時 |
YAMLだけを書き換えると、ファイル日時ソートには反映されません。またYAMLを書き換える行為そのものにより、実ファイルの更新日時は作業時刻になります。ファイル日時ソートも使うなら、YAML反映の後に実ファイル日時を設定します。
全体の流れ
Evernote ENEX
│
▼
[1] ENEX → CSV
│ title / created_at_local / updated_at_local
▼
[2] CSVとObsidian Markdownをタイトルで1対1照合
│
├─ [3] YAMLのcreated・updatedを反映
│
└─ [4] Windows実ファイルの作成日時・更新日時を反映
どの更新スクリプトも、--applyを付けない限りファイルを変更しません。まず照合レポートを確認し、対象が正しいことを確認してから--applyを付けます。
事前準備
必要なファイル
次の3ファイルを同じフォルダーへ保存します。3本目のスクリプトは2本目を読み込むため、別フォルダーに分けないでください。
evernote-migration/
├── evernote_enex_to_csv.py
├── obsidian_apply_evernote_created.py
├── obsidian_set_evernote_file_times.py
└── (Evernoteからエクスポートした *.enex)
以下では、例として次のダミー環境を使います。自分の環境へ置き換えてください。
| 項目 | 例 |
|---|---|
| スクリプトとENEXのフォルダー | C:\Users\your-name\Documents\evernote-migration |
| Obsidian Vault | C:\Users\your-name\Documents\Obsidian\MyVault |
| 移行対象フォルダー |
Imported-Evernote、Archive-Evernote
|
| 除外したいフォルダー | Imported-Evernote\Attachments |
記事中のパス、ユーザー名、フォルダー名、日時、件数はすべて説明用のダミーです。
EvernoteからENEXを書き出す
Evernoteで移行対象のノートまたはノートブックを選択し、ENEX(.enex)形式でエクスポートします。複数のノートブックを複数のENEXファイルとして書き出しても構いません。
1. ENEXから作成日時・更新日時・タイトルをCSVへ抽出する
PowerShellでスクリプトとENEXがあるフォルダーを開き、次を実行します。
py .\evernote_enex_to_csv.py . -o evernote_notes.csv
1つのENEXだけを指定する場合は、次のようにします。
py .\evernote_enex_to_csv.py "MyNotebook.enex" -o evernote_notes.csv
既定では日本時間(Asia/Tokyo)へ換算します。別のタイムゾーンを使う場合は--timezoneを指定します。
py .\evernote_enex_to_csv.py . -o evernote_notes.csv --timezone Europe/London
出力CSVの主要列は次のとおりです。
| 列 | 内容 |
|---|---|
title |
Evernoteノートのタイトル。Obsidianとの照合キー |
created_at_local |
ローカル時間の作成日時。YYYY-MM-DD HH:MM
|
updated_at_local |
ローカル時間の更新日時。YYYY-MM-DD HH:MM
|
created_at_utc / updated_at_utc
|
元のUTC日時。検証用 |
source_enex / note_number
|
元のENEXと連番。同名ノートを手動確認する際に利用 |
このCSVへノート本文は出力されません。
2. ObsidianのYAMLプロパティへ反映する
照合のルール
既定では、Obsidianノートのファイル名(拡張子なし)と、本文内の最初の# 見出しをタイトル候補としてCSVのtitleと比較します。次の条件を満たすノートだけを自動更新します。
- Evernote CSV側のタイトルが1件だけである。
- Obsidian側のノートが1件だけに特定できる。
- ファイル名とH1見出しの両方がCSVに一致する場合、同じEvernoteノートを指している。
Evernote側の半角スラッシュ/と、Obsidian移行時に使われやすい全角スラッシュ/は同じ文字として扱います。
Evernote: 製品/サービス計画
Obsidian: 製品/サービス計画.md
この2つは一致します。他の記号を広く置換すると誤一致の原因になるため、自動変換はしません。
対象フォルダーを限定する
実際の更新では、--include-dirを必ず指定します。これにより、対象外の.mdまで更新されることを防げます。--exclude-dirは、対象フォルダー内でさらに除外したい子フォルダーに使います。
--include-dir "Imported-Evernote" --include-dir "Archive-Evernote" --exclude-dir "Imported-Evernote\Attachments"
まず照合レポートだけを作る
このコマンドはMarkdownを変更しません。
py .\obsidian_apply_evernote_created.py .\evernote_notes.csv "C:\Users\your-name\Documents\Obsidian\MyVault" --include-dir "Imported-Evernote" --include-dir "Archive-Evernote" --exclude-dir "Imported-Evernote\Attachments" --report "$env:USERPROFILE\Desktop\evernote_obsidian_match_report.csv"
レポートのstatusとactionを確認します。
status / action
|
意味 |
|---|---|
matched + would_add_frontmatter(...)
|
YAMLがないノートにYAMLを新設する予定 |
matched + would_add_property(...)
|
YAMLはあるが日時プロパティがないため追加する予定 |
matched + would_update_property(...)
|
既存日時をEvernote CSVの値で上書きする予定 |
duplicate_evernote_title |
CSVに同名ノートが複数ある。更新しない |
duplicate_obsidian_match |
1件のEvernoteノートに複数Markdownが一致する。更新しない |
ambiguous_title_sources |
ファイル名とH1が別のEvernoteノートに一致する。更新しない |
unmatched_obsidian / unmatched_evernote
|
一致相手がない。更新しない |
YAMLを反映する
レポートを確認後、Obsidianを閉じるか同期が完了していることを確認してから、同じコマンドの末尾へ--applyを付けます。
py .\obsidian_apply_evernote_created.py .\evernote_notes.csv "C:\Users\your-name\Documents\Obsidian\MyVault" --include-dir "Imported-Evernote" --include-dir "Archive-Evernote" --exclude-dir "Imported-Evernote\Attachments" --report "$env:USERPROFILE\Desktop\evernote_obsidian_apply_report.csv" --apply
更新前のMarkdownはVault内の_evernote_datetime_backup_YYYYMMDD_HHMMSSへ自動バックアップされます。
3. Windows実ファイル日時をEvernote日時へ反映する
YAML反映後、Windows実ファイル日時も合わせます。この処理はWindows専用です。Windowsの作成日時・更新日時を直接変更するため、同期中のフォルダーで実行する場合は同期を一時停止するか、同期完了を待ってください。
まず照合レポートだけを作る
py .\obsidian_set_evernote_file_times.py .\evernote_notes.csv "C:\Users\your-name\Documents\Obsidian\MyVault" --include-dir "Imported-Evernote" --include-dir "Archive-Evernote" --exclude-dir "Imported-Evernote\Attachments" --report "$env:USERPROFILE\Desktop\evernote_file_times_match_report.csv"
レポートでは、次の列を確認します。
| 列 | 内容 |
|---|---|
obsidian_path |
更新対象が想定フォルダー内か |
desired_created_at / desired_updated_at
|
設定予定のEvernote日時 |
current_created_at / current_updated_at
|
現在のWindows実ファイル日時 |
action |
would_set_creation_and_modifiedまたはwould_set_creationなら更新候補 |
Evernoteの更新日時がないノートは、would_set_creationとなり、作成日時だけを設定します。
実ファイル日時を反映する
更新前の実ファイル日時をCSVへバックアップし、タイトルが1対1で一致したノートだけを更新します。
py .\obsidian_set_evernote_file_times.py .\evernote_notes.csv "C:\Users\your-name\Documents\Obsidian\MyVault" --include-dir "Imported-Evernote" --include-dir "Archive-Evernote" --exclude-dir "Imported-Evernote\Attachments" --report "$env:USERPROFILE\Desktop\evernote_file_times_apply_report.csv" --timestamp-backup "$env:USERPROFILE\Desktop\evernote_file_times_before_change.csv" --apply
完了後、WindowsエクスプローラーでMarkdownを右クリックし、プロパティ → 全般から作成日時・更新日時を確認できます。
4. 復元方法
YAMLを反映する前の状態へ戻す
YAML反映時に作られた_evernote_datetime_backup_...フォルダーから、戻したいMarkdownだけを同じ相対パスへコピーします。復元前に現在のファイルを別の場所へ退避してください。
Windows実ファイル日時を戻す
--timestamp-backupで作ったCSVを使います。復元対象を確認したうえで、次を実行します。
py .\obsidian_set_evernote_file_times.py .\evernote_notes.csv "C:\Users\your-name\Documents\Obsidian\MyVault" --restore-backup "$env:USERPROFILE\Desktop\evernote_file_times_before_change.csv" --report "$env:USERPROFILE\Desktop\evernote_file_times_restore_report.csv" --apply
--restore-backupでは--applyが必須です。日時バックアップCSVは削除せず、Vaultとは別の安全な場所にも保存することを勧めます。
よくあるつまずき
| 症状 | 原因と対処 |
|---|---|
PermissionErrorでCSVレポートを書けない |
Excelで同名CSVを開いていることが多い。Excelを閉じ、別名で出力する |
PowerShellで>>が表示される |
コマンドが途中で終わっていない。Ctrl + Cで中断してから、コードブロックの1行だけを実行する |
--applyだけを入力してエラーになる |
--applyは単独コマンドではない。スクリプトのコマンド末尾に付ける |
| 意図しないMarkdownも候補になる |
--include-dirで対象を絞り、必要に応じて--exclude-dirを追加する |
| 同名ノートが更新されない | 安全設計により自動更新しない。CSVのsource_enex・note_numberとノート内容を確認して手動対応する |
| YAMLのプロパティ名を変えたい | 初回反映前なら--created-property・--updated-propertyを指定する。反映後に別名で実行すると旧プロパティは残る |
セキュリティと運用上の注意
- 3本のスクリプトはPython標準ライブラリだけを使い、外部通信を行いません。
- ENEX本文はXMLを読むためにローカルメモリ上で扱いますが、抽出CSVに書き出すのはタイトルと日時などのメタデータだけです。
- レポートCSVにはノートタイトル、相対パス、日時が含まれます。公開リポジトリーや共有フォルダーへ保存する際は注意してください。
- 最初に照合レポートを確認し、実際の更新前にはVault全体と日時バックアップCSVを保全してください。
スクリプト全文
以下の3つをそれぞれ同名の.pyファイルとして保存してください。
evernote_enex_to_csv.py
#!/usr/bin/env python3
"""Evernote ENEXからノートの作成日時・更新日時・タイトルだけをCSVへ抽出する。
外部通信や追加ライブラリは不要です。ノート本文はCSVへ出力せず、読み取り後も保存しません。
例:
python3 evernote_enex_to_csv.py notes.enex -o evernote_notes.csv
python3 evernote_enex_to_csv.py exports/ -o evernote_notes.csv --timezone Asia/Tokyo
"""
from __future__ import annotations
import argparse
import csv
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator
from xml.etree import ElementTree as ET
def parse_evernote_datetime(value: str) -> datetime:
"""EvernoteのUTC日時(例: 20240203T045607Z)をdatetimeに変換する。"""
value = (value or "").strip()
formats = (
"%Y%m%dT%H%M%SZ",
"%Y%m%dT%H%M%S%z",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S%z",
)
for fmt in formats:
try:
parsed = datetime.strptime(value, fmt)
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
except ValueError:
pass
raise ValueError(f"未対応の日時形式です: {value!r}")
def enex_files(path: Path) -> list[Path]:
"""ファイルまたはディレクトリから処理対象のENEX一覧を返す。"""
if path.is_file():
if path.suffix.lower() != ".enex":
raise ValueError("入力ファイルは .enex 形式を指定してください。")
return [path]
if path.is_dir():
files = sorted(p for p in path.rglob("*") if p.is_file() and p.suffix.lower() == ".enex")
if not files:
raise ValueError("指定フォルダ内に .enex ファイルが見つかりません。")
return files
raise ValueError(f"入力パスが見つかりません: {path}")
def notes_from_enex(path: Path) -> Iterator[tuple[str, str, str]]:
"""ENEXを順次読み、(作成日時, 更新日時, タイトル)を返す。本文は取り出さない。"""
try:
# iterparseは大きなENEXでも全ノートをメモリに保持しない。
for _event, element in ET.iterparse(path, events=("end",)):
if element.tag != "note":
continue
created = (element.findtext("created") or "").strip()
updated = (element.findtext("updated") or "").strip()
title = (element.findtext("title") or "").strip()
if not created:
print(f"警告: 作成日時がないノートをスキップしました(ファイル: {path.name})。", file=sys.stderr)
else:
yield created, updated, title
element.clear()
except ET.ParseError as exc:
raise ValueError(f"ENEXとして読み取れないXMLです: {path.name} ({exc})") from exc
def datetime_columns(value: str, local_tz) -> tuple[str, str, str]:
"""ENEX日時を、UTC・ローカル日時・ローカル日付の3列へ変換する。"""
if not value:
return "", "", ""
utc_time = parse_evernote_datetime(value).astimezone(timezone.utc)
local_time = utc_time.astimezone(local_tz)
# Obsidianの既存形式 created: 2026-08-17 09:14 に合わせ、秒・タイムゾーン表記は除く。
return (
utc_time.isoformat().replace("+00:00", "Z"),
local_time.strftime("%Y-%m-%d %H:%M"),
local_time.strftime("%Y-%m-%d"),
)
def main() -> int:
parser = argparse.ArgumentParser(
description="EvernoteのENEXファイルから作成日時・更新日時・タイトルだけをCSVへ出力します。"
)
parser.add_argument("input", type=Path, help="ENEXファイル、またはENEXを含むフォルダ")
parser.add_argument(
"-o", "--output", type=Path, default=Path("evernote_notes.csv"),
help="出力CSVのパス(既定: evernote_notes.csv)"
)
parser.add_argument(
"--timezone", default="Asia/Tokyo",
help="表示用ローカル日時のタイムゾーン(既定: Asia/Tokyo)。例: UTC, Europe/London"
)
args = parser.parse_args()
try:
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(args.timezone)
except Exception as exc:
print(f"エラー: タイムゾーン {args.timezone!r} を利用できません: {exc}", file=sys.stderr)
return 2
try:
inputs = enex_files(args.input)
except ValueError as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 2
args.output.parent.mkdir(parents=True, exist_ok=True)
count = 0
try:
# utf-8-sigによりWindows版Excelでも日本語を文字化けしにくくする。
with args.output.open("w", encoding="utf-8-sig", newline="") as csv_file:
writer = csv.DictWriter(
csv_file,
fieldnames=[
"source_enex",
"note_number",
"created_at_utc",
"created_at_local",
"created_date_local",
"updated_at_utc",
"updated_at_local",
"updated_date_local",
"title",
],
)
writer.writeheader()
for input_file in inputs:
number_in_file = 0
for created_value, updated_value, title in notes_from_enex(input_file):
number_in_file += 1
created_utc, created_local, created_date = datetime_columns(created_value, local_tz)
updated_utc, updated_local, updated_date = datetime_columns(updated_value, local_tz)
if not updated_value:
print(
f"警告: 更新日時がないノートです(作成日時のみ出力): {input_file.name} #{number_in_file}",
file=sys.stderr,
)
writer.writerow({
"source_enex": input_file.name,
"note_number": number_in_file,
"created_at_utc": created_utc,
"created_at_local": created_local,
"created_date_local": created_date,
"updated_at_utc": updated_utc,
"updated_at_local": updated_local,
"updated_date_local": updated_date,
"title": title,
})
count += 1
except (OSError, ValueError) as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 1
print(f"完了: {count}件のノートを {args.output} に出力しました。")
print("出力列: source_enex, note_number, created_at_utc, created_at_local, created_date_local, updated_at_utc, updated_at_local, updated_date_local, title")
return 0
if __name__ == "__main__":
raise SystemExit(main())
obsidian_apply_evernote_created.py
#!/usr/bin/env python3
"""Evernote作成日時CSVをObsidian Markdownへ反映する(ローカル専用)。
既定では照合レポートを作るだけで、Markdownファイルを変更しない。
--apply を付けた場合だけ、タイトルが一意に一致したノートのYAMLプロパティを更新する。
既存のcreated / updated値があっても、Evernote CSVの値を優先して上書きする。
"""
from __future__ import annotations
import argparse
import csv
import os
import re
import shutil
import sys
import tempfile
import unicodedata
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
@dataclass(frozen=True)
class EvernoteRow:
row_number: int
title: str
created_at: str
updated_at: str
source_enex: str
note_number: str
@dataclass
class ObsidianNote:
path: Path
relative_path: Path
stem_title: str
h1_title: str | None
candidates: list[tuple[str, str]]
content: str
@dataclass
class MatchResult:
note: ObsidianNote | None
evernote: EvernoteRow | None
status: str
action: str
detail: str
def normalize_title(value: str) -> str:
"""安全な完全一致用に文字表記・空白・英字大文字小文字を正規化する。
Evernoteの半角スラッシュ(/)と、Obsidian移行時に使われる全角スラッシュ(/)は
同一の区切り記号として扱う。他の記号は意図せず一致範囲を広げないため変更しない。
"""
normalized = unicodedata.normalize("NFC", value).replace("/", "/")
return " ".join(normalized.split()).casefold()
def safe_csv_value(row: dict[str, str], name: str) -> str:
return (row.get(name) or "").strip()
def validate_datetime(value: str, label: str, line_number: int) -> None:
"""Obsidianの既存書式 YYYY-MM-DD HH:MM に限定する。"""
try:
datetime.strptime(value, "%Y-%m-%d %H:%M")
except ValueError as exc:
raise ValueError(
f"CSV {line_number}行目の{label}がYYYY-MM-DD HH:MMではありません: {value!r}"
) from exc
def read_evernote_csv(
path: Path, created_column: str, updated_column: str, include_updated: bool
) -> list[EvernoteRow]:
try:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise ValueError("CSVのヘッダー行が見つかりません。")
required = {"title", created_column}
if include_updated:
required.add(updated_column)
missing = required - set(reader.fieldnames)
if missing:
raise ValueError(
f"CSVに必要な列がありません: {', '.join(sorted(missing))}。"
f"更新済みのevernote_enex_to_csv.pyでCSVを作り直してください。"
)
rows: list[EvernoteRow] = []
for line_number, row in enumerate(reader, start=2):
title = safe_csv_value(row, "title")
created_at = safe_csv_value(row, created_column)
updated_at = safe_csv_value(row, updated_column) if include_updated else ""
if not title or not created_at:
print(
f"警告: CSV {line_number}行目はtitleまたは{created_column}が空のため除外しました。",
file=sys.stderr,
)
continue
validate_datetime(created_at, created_column, line_number)
if updated_at:
validate_datetime(updated_at, updated_column, line_number)
rows.append(
EvernoteRow(
row_number=line_number,
title=title,
created_at=created_at,
updated_at=updated_at,
source_enex=safe_csv_value(row, "source_enex"),
note_number=safe_csv_value(row, "note_number"),
)
)
except UnicodeDecodeError as exc:
raise ValueError("CSVをUTF-8として読み取れません。抽出スクリプトの出力CSVを指定してください。") from exc
return rows
def first_h1(markdown: str) -> str | None:
"""先頭のATX形式H1(# タイトル)を取得する。コードブロック内は対象外。"""
in_fence = False
for line in markdown.splitlines():
stripped = line.strip()
if re.match(r"^(```|~~~)", stripped):
in_fence = not in_fence
continue
if in_fence:
continue
matched = re.match(r"^\s{0,3}#\s+(.+?)\s*#*\s*$", line)
if matched:
return matched.group(1).strip()
return None
def is_within(path: Path, directory: Path) -> bool:
"""pathがdirectory自身またはその配下にあるかを判定する。"""
try:
path.relative_to(directory)
return True
except ValueError:
return False
def resolve_vault_directories(vault: Path, raw_paths: list[str], option_name: str) -> list[Path]:
"""Vault内に限定した対象・除外フォルダー指定を絶対パスへ解決する。"""
vault_root = vault.resolve()
resolved: list[Path] = []
seen: set[Path] = set()
for raw_path in raw_paths:
supplied = Path(raw_path).expanduser()
candidate = (supplied if supplied.is_absolute() else vault_root / supplied).resolve()
if not is_within(candidate, vault_root):
raise ValueError(f"{option_name}はVault内のフォルダーだけを指定できます: {raw_path}")
if not candidate.is_dir():
raise ValueError(f"{option_name}で指定したフォルダーが見つかりません: {raw_path}")
if candidate not in seen:
resolved.append(candidate)
seen.add(candidate)
return resolved
def read_markdown_notes(
vault: Path,
title_source: str,
include_dirs: list[Path],
exclude_dirs: list[Path],
) -> list[ObsidianNote]:
"""指定された対象フォルダーだけから、除外フォルダーを避けてMarkdownを読む。"""
vault_root = vault.resolve()
search_roots = include_dirs or [vault_root]
candidate_paths: set[Path] = set()
for root in search_roots:
for path in root.rglob("*.md"):
resolved_path = path.resolve()
if not is_within(resolved_path, vault_root):
print(f"警告: Vault外を指すためスキップしました: {path}", file=sys.stderr)
continue
if any(is_within(resolved_path, excluded) for excluded in exclude_dirs):
continue
candidate_paths.add(resolved_path)
notes: list[ObsidianNote] = []
for path in sorted(candidate_paths):
relative = path.relative_to(vault_root)
try:
content = path.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
print(f"警告: UTF-8として読めないためスキップしました: {relative}", file=sys.stderr)
continue
stem_title = path.stem.strip()
h1_title = first_h1(content)
candidates: list[tuple[str, str]] = []
if title_source in {"stem", "either"} and stem_title:
candidates.append(("file_stem", stem_title))
if title_source in {"h1", "either"} and h1_title:
candidates.append(("h1", h1_title))
unique_candidates: list[tuple[str, str]] = []
seen: set[str] = set()
for source, title in candidates:
key = normalize_title(title)
if key not in seen:
unique_candidates.append((source, title))
seen.add(key)
notes.append(ObsidianNote(path, relative, stem_title, h1_title, unique_candidates, content))
return notes
def prepare_matches(evernote_rows: list[EvernoteRow], notes: list[ObsidianNote]) -> list[MatchResult]:
by_title: dict[str, list[EvernoteRow]] = defaultdict(list)
for item in evernote_rows:
by_title[normalize_title(item.title)].append(item)
results: list[MatchResult] = []
for note in notes:
if not note.candidates:
results.append(MatchResult(note, None, "missing_match_title", "skipped", "指定された照合タイトルが見つかりません。"))
continue
candidate_groups: list[tuple[str, list[EvernoteRow]]] = []
for source, title in note.candidates:
found = by_title.get(normalize_title(title), [])
if found:
candidate_groups.append((source, found))
if not candidate_groups:
searched = " / ".join(f"{source}: {title}" for source, title in note.candidates)
results.append(MatchResult(note, None, "unmatched_obsidian", "skipped", f"CSVに一致タイトルがありません({searched})。"))
continue
all_found = [row for _source, rows in candidate_groups for row in rows]
unique_ids = {row.row_number for row in all_found}
if any(len(rows) > 1 for _source, rows in candidate_groups):
results.append(MatchResult(note, None, "duplicate_evernote_title", "skipped", "CSV内に同じタイトルのEvernoteノートが複数あります。自動更新しません。"))
continue
if len(unique_ids) != 1:
results.append(MatchResult(note, None, "ambiguous_title_sources", "skipped", "ファイル名とH1が別々のEvernoteノートに一致します。自動更新しません。"))
continue
matched = all_found[0]
sources = ", ".join(source for source, _rows in candidate_groups)
results.append(MatchResult(note, matched, "matched", "pending", f"一致方法: {sources}"))
# 同じEvernoteの1件へ複数Markdownが一致した場合は、両方とも更新しない。
matched_by_evernote: dict[int, list[MatchResult]] = defaultdict(list)
for result in results:
if result.status == "matched" and result.evernote:
matched_by_evernote[result.evernote.row_number].append(result)
for group in matched_by_evernote.values():
if len(group) > 1:
paths = ", ".join(str(item.note.relative_path) for item in group if item.note)
for item in group:
item.status = "duplicate_obsidian_match"
item.action = "skipped"
item.detail = f"同じEvernoteノートに複数のMarkdownが一致します: {paths}"
# CSV側だけにあるノートもレポートで見えるようにする。
accounted_ids = {result.evernote.row_number for result in results if result.evernote is not None}
for row in evernote_rows:
if row.row_number not in accounted_ids:
duplicate = len(by_title[normalize_title(row.title)]) > 1
status = "duplicate_evernote_title" if duplicate else "unmatched_evernote"
detail = "CSV内に同じタイトルが複数あります。" if duplicate else "Obsidian側に一意に一致するMarkdownがありません。"
results.append(MatchResult(None, row, status, "manual_review", detail))
return results
def find_frontmatter_end(lines: list[str]) -> int | None:
if not lines or lines[0].lstrip("\ufeff").strip() != "---":
return None
for index in range(1, len(lines)):
if lines[index].strip() in {"---", "..."}:
return index
return None
def property_actions(markdown: str, properties: dict[str, str]) -> list[str]:
lines = markdown.splitlines(keepends=True)
end_index = find_frontmatter_end(lines)
if end_index is None:
return [f"add_frontmatter({name})" for name in properties]
actions: list[str] = []
for name in properties:
pattern = re.compile(rf"^\s*{re.escape(name)}\s*:", flags=re.IGNORECASE)
exists = any(pattern.match(line) for line in lines[1:end_index])
actions.append(("update_property" if exists else "add_property") + f"({name})")
return actions
def apply_yaml_properties(markdown: str, properties: dict[str, str]) -> str:
"""既存のプロパティはCSV値で上書きし、不足分は追加する。"""
newline = "\r\n" if "\r\n" in markdown else "\n"
lines = markdown.splitlines(keepends=True)
end_index = find_frontmatter_end(lines)
if end_index is None:
property_lines = "".join(f"{name}: {value}{newline}" for name, value in properties.items())
return f"---{newline}{property_lines}---{newline}{newline}" + markdown
remaining = dict(properties)
for index in range(1, end_index):
for name, value in list(remaining.items()):
pattern = re.compile(rf"^(\s*){re.escape(name)}\s*:.*(?:\r?\n)?$", flags=re.IGNORECASE)
matched = pattern.match(lines[index])
if matched:
# 既存の大小文字やインデントではなく、指定された名前・CSV値で統一する。
lines[index] = f"{matched.group(1)}{name}: {value}{newline}"
del remaining[name]
break
insertion_index = end_index
for name, value in remaining.items():
lines.insert(insertion_index, f"{name}: {value}{newline}")
insertion_index += 1
return "".join(lines)
def backup_and_write(note: ObsidianNote, new_content: str, backup_dir: Path) -> None:
backup_path = backup_dir / note.relative_path
backup_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(note.path, backup_path)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="", delete=False, dir=note.path.parent, prefix=".evernote-date-") as temp:
temp.write(new_content)
temp_path = Path(temp.name)
try:
shutil.copymode(note.path, temp_path)
os.replace(temp_path, note.path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def write_report(path: Path, results: Iterable[MatchResult]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(
handle,
fieldnames=[
"status", "action", "obsidian_path", "obsidian_file_stem", "obsidian_h1",
"evernote_title", "evernote_created_at", "evernote_updated_at", "source_enex",
"evernote_note_number", "evernote_csv_row", "detail",
],
)
writer.writeheader()
for result in results:
note, evernote = result.note, result.evernote
writer.writerow({
"status": result.status,
"action": result.action,
"obsidian_path": str(note.relative_path) if note else "",
"obsidian_file_stem": note.stem_title if note else "",
"obsidian_h1": (note.h1_title or "") if note else "",
"evernote_title": evernote.title if evernote else "",
"evernote_created_at": evernote.created_at if evernote else "",
"evernote_updated_at": evernote.updated_at if evernote else "",
"source_enex": evernote.source_enex if evernote else "",
"evernote_note_number": evernote.note_number if evernote else "",
"evernote_csv_row": evernote.row_number if evernote else "",
"detail": result.detail,
})
def main() -> int:
parser = argparse.ArgumentParser(
description="Evernote作成・更新日時CSVとObsidianノートをタイトルで一意照合し、YAMLを更新します。"
)
parser.add_argument("csv_file", type=Path, help="evernote_enex_to_csv.pyが出力したCSV")
parser.add_argument("vault", type=Path, help="Obsidian保管庫(Vault)のフォルダ")
parser.add_argument("--created-property", default="created", help="作成日時のYAMLプロパティ名(既定: created)")
parser.add_argument("--updated-property", default="updated", help="更新日時のYAMLプロパティ名(既定: updated)")
parser.add_argument("--created-column", default="created_at_local", help="CSV上の作成日時列(既定: created_at_local)")
parser.add_argument("--updated-column", default="updated_at_local", help="CSV上の更新日時列(既定: updated_at_local)")
parser.add_argument("--no-updated", action="store_true", help="createdだけを更新する。updated列は参照しない。")
parser.add_argument(
"--title-source", choices=["stem", "h1", "either"], default="either",
help="照合に使うObsidianタイトル。stem=ファイル名、h1=最初の#見出し、either=両方(既定)"
)
parser.add_argument(
"--include-dir", action="append", default=[], metavar="VAULT内の相対パス",
help="処理対象にするVault内フォルダー。複数回指定可。--apply時は少なくとも1つ必須。全Vaultを明示する場合は --include-dir ."
)
parser.add_argument(
"--exclude-dir", action="append", default=[], metavar="VAULT内の相対パス",
help="走査・更新から除外するVault内フォルダー。複数回指定可。対象フォルダー内の子フォルダーも除外する。"
)
parser.add_argument("--report", type=Path, default=Path("evernote_obsidian_match_report.csv"), help="照合レポートCSVの保存先")
parser.add_argument("--apply", action="store_true", help="一致したノートを実際に更新する。指定しない場合は照合のみ。")
parser.add_argument("--backup-dir", type=Path, help="更新前Markdownのバックアップ先(--apply時のみ)")
args = parser.parse_args()
for name, label in ((args.created_property, "--created-property"), (args.updated_property, "--updated-property")):
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", name):
print(f"エラー: {label}には英数字、_、-だけを使ってください。", file=sys.stderr)
return 2
if not args.no_updated and args.created_property.casefold() == args.updated_property.casefold():
print("エラー: 作成日時と更新日時のプロパティ名は別々にしてください。", file=sys.stderr)
return 2
if not args.csv_file.is_file():
print(f"エラー: CSVが見つかりません: {args.csv_file}", file=sys.stderr)
return 2
if not args.vault.is_dir():
print(f"エラー: Obsidian保管庫フォルダが見つかりません: {args.vault}", file=sys.stderr)
return 2
try:
evernote_rows = read_evernote_csv(args.csv_file, args.created_column, args.updated_column, not args.no_updated)
except ValueError as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 2
if not evernote_rows:
print("エラー: 有効なEvernoteノートがCSVにありません。", file=sys.stderr)
return 2
try:
include_dirs = resolve_vault_directories(args.vault, args.include_dir, "--include-dir")
exclude_dirs = resolve_vault_directories(args.vault, args.exclude_dir, "--exclude-dir")
except ValueError as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 2
if args.apply and not include_dirs:
print("エラー: --apply時は--include-dirで処理対象フォルダーを明示してください。Vault全体を対象にする場合も --include-dir . を指定してください。", file=sys.stderr)
return 2
vault_root = args.vault.resolve()
# Obsidianの設定フォルダーは常に除外する。存在しなくても除外パスとして安全に扱える。
exclude_dirs.append(vault_root / ".obsidian")
backup_dir: Path | None = None
if args.apply:
backup_dir = args.backup_dir or (vault_root / f"_evernote_datetime_backup_{datetime.now():%Y%m%d_%H%M%S}")
backup_dir = backup_dir.expanduser().resolve()
if backup_dir.exists():
print(f"エラー: バックアップ先が既に存在します: {backup_dir}", file=sys.stderr)
return 2
# 新設するバックアップフォルダー自体を走査対象から外す。
if is_within(backup_dir, vault_root):
exclude_dirs.append(backup_dir)
notes = read_markdown_notes(args.vault, args.title_source, include_dirs, exclude_dirs)
results = prepare_matches(evernote_rows, notes)
matched = [item for item in results if item.status == "matched" and item.note and item.evernote]
for item in matched:
assert item.evernote is not None
properties = {args.created_property: item.evernote.created_at}
if not args.no_updated and item.evernote.updated_at:
properties[args.updated_property] = item.evernote.updated_at
elif not args.no_updated:
item.detail += "; CSVの更新日時が空のためupdatedは変更しません。"
actions = property_actions(item.note.content, properties) if item.note else []
if not args.apply:
item.action = "would_" + "; would_".join(actions)
item.detail += "; --applyを付ければCSV値で上書き・追加します。"
continue
assert backup_dir is not None and item.note is not None
try:
new_content = apply_yaml_properties(item.note.content, properties)
backup_and_write(item.note, new_content, backup_dir)
except OSError as exc:
item.status = "write_error"
item.action = "not_updated"
item.detail += f"; 書込みに失敗しました。バックアップ先: {backup_dir} ({exc})"
print(f"エラー: {item.note.relative_path} を更新できません: {exc}", file=sys.stderr)
continue
item.action = "; ".join(actions)
applied = ", ".join(f"{name}: {value}" for name, value in properties.items())
item.detail += f"; CSV値を反映しました({applied})。"
write_report(args.report, results)
counts: dict[str, int] = defaultdict(int)
for item in results:
counts[item.status] += 1
print(f"照合レポートを作成しました: {args.report}")
target_description = ", ".join(str(path.relative_to(vault_root)) or "." for path in include_dirs) if include_dirs else "Vault全体(照合モードのみ)"
explicit_excludes = [path for path in exclude_dirs if path != vault_root / ".obsidian" and path != backup_dir]
exclude_description = ", ".join(str(path.relative_to(vault_root)) for path in explicit_excludes) if explicit_excludes else "指定なし"
print(f"対象フォルダー: {target_description} / 除外フォルダー: {exclude_description}")
print(f"Obsidian Markdown: {len(notes)}件 / Evernote CSV: {len(evernote_rows)}件")
print("結果: " + ", ".join(f"{status}={count}" for status, count in sorted(counts.items())))
if args.apply:
applied_count = sum(1 for item in results if item.status == "matched" and item.action not in {"pending", "not_updated"})
print(f"更新したノート: {applied_count}件")
print(f"更新前バックアップ: {backup_dir}")
else:
print("Markdownファイルは変更していません。レポートを確認後、--applyを付けて再実行してください。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
obsidian_set_evernote_file_times.py
#!/usr/bin/env python3
"""Evernote CSVを基に、Windows上のObsidian Markdownのファイル日時を設定する。
既定では照合レポートだけを作り、ファイルの日時は変更しない。
--apply を付けた場合だけ、タイトルが一意に一致したMarkdownについて、
Windowsファイルシステムの作成日時と更新日時をEvernote日時へ変更する。
このスクリプトは obsidian_apply_evernote_created.py と同じフォルダーに置いて実行する。
"""
from __future__ import annotations
import argparse
import csv
import ctypes
import os
import platform
import re
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
from obsidian_apply_evernote_created import (
EvernoteRow,
MatchResult,
is_within,
prepare_matches,
read_evernote_csv,
read_markdown_notes,
resolve_vault_directories,
)
DATE_FORMAT = "%Y-%m-%d %H:%M"
BACKUP_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
def parse_local_datetime(value: str, timezone_name: str) -> datetime:
"""CSVのローカル日時を指定タイムゾーン付きdatetimeへ変換する。"""
try:
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(timezone_name)
except Exception as exc:
raise ValueError(f"タイムゾーン {timezone_name!r} を利用できません: {exc}") from exc
try:
return datetime.strptime(value, DATE_FORMAT).replace(tzinfo=local_tz)
except ValueError as exc:
raise ValueError(f"日時が{DATE_FORMAT}形式ではありません: {value!r}") from exc
def format_file_timestamp(timestamp: float, timezone_name: str) -> str:
"""Windowsのファイル時刻をレポート・バックアップ用のローカル日時文字列にする。"""
try:
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(timezone_name)
except Exception as exc:
raise ValueError(f"タイムゾーン {timezone_name!r} を利用できません: {exc}") from exc
return datetime.fromtimestamp(timestamp, local_tz).strftime(BACKUP_DATE_FORMAT)
def read_current_file_times(path: Path, timezone_name: str) -> tuple[str, str]:
"""Windowsの作成日時と更新日時を取得する。Windowsではst_ctimeが作成日時を表す。"""
stat = path.stat()
return (
format_file_timestamp(stat.st_ctime, timezone_name),
format_file_timestamp(stat.st_mtime, timezone_name),
)
def datetime_to_filetime(value: datetime) -> tuple[int, int]:
"""タイムゾーン付きdatetimeをWindows FILETIMEの下位・上位DWORDに変換する。"""
if value.tzinfo is None:
raise ValueError("タイムゾーン付き日時が必要です。")
utc_value = value.astimezone(timezone.utc)
# FILETIMEは1601-01-01 UTCからの100ナノ秒単位。
windows_epoch_seconds = 11644473600
unix_seconds = int(utc_value.timestamp())
filetime = (unix_seconds + windows_epoch_seconds) * 10_000_000
return filetime & 0xFFFFFFFF, (filetime >> 32) & 0xFFFFFFFF
def set_windows_file_times(path: Path, created_at: datetime, updated_at: datetime | None) -> None:
"""Windows APIでファイルの作成日時と更新日時を設定する。アクセス日時は維持する。"""
if os.name != "nt":
raise OSError("実ファイル日時の変更はWindowsでのみ実行できます。")
from ctypes import wintypes
class FILETIME(ctypes.Structure):
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
create_file = kernel32.CreateFileW
create_file.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
wintypes.LPVOID,
wintypes.DWORD,
wintypes.DWORD,
wintypes.HANDLE,
]
create_file.restype = wintypes.HANDLE
set_file_time = kernel32.SetFileTime
set_file_time.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(FILETIME),
ctypes.POINTER(FILETIME),
ctypes.POINTER(FILETIME),
]
set_file_time.restype = wintypes.BOOL
close_handle = kernel32.CloseHandle
close_handle.argtypes = [wintypes.HANDLE]
close_handle.restype = wintypes.BOOL
FILE_WRITE_ATTRIBUTES = 0x0100
FILE_SHARE_READ = 0x00000001
FILE_SHARE_WRITE = 0x00000002
FILE_SHARE_DELETE = 0x00000004
OPEN_EXISTING = 3
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
created_low, created_high = datetime_to_filetime(created_at)
created_filetime = FILETIME(created_low, created_high)
updated_filetime: FILETIME | None = None
if updated_at is not None:
updated_low, updated_high = datetime_to_filetime(updated_at)
updated_filetime = FILETIME(updated_low, updated_high)
handle = create_file(
str(path),
FILE_WRITE_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
None,
OPEN_EXISTING,
0,
None,
)
if handle == INVALID_HANDLE_VALUE:
error = ctypes.get_last_error()
raise OSError(error, f"ファイルを開けません: {path}")
try:
success = set_file_time(
handle,
ctypes.byref(created_filetime),
None,
ctypes.byref(updated_filetime) if updated_filetime is not None else None,
)
if not success:
error = ctypes.get_last_error()
raise OSError(error, f"ファイル日時を設定できません: {path}")
finally:
close_handle(handle)
def read_backup_rows(backup_file: Path) -> list[dict[str, str]]:
try:
with backup_file.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
required = {"obsidian_path", "original_created_at", "original_updated_at"}
if not reader.fieldnames or not required.issubset(reader.fieldnames):
raise ValueError("日時バックアップCSVの形式が正しくありません。")
return list(reader)
except UnicodeDecodeError as exc:
raise ValueError("日時バックアップCSVをUTF-8として読み取れません。") from exc
def parse_backup_datetime(value: str, timezone_name: str) -> datetime:
try:
naive = datetime.strptime(value, BACKUP_DATE_FORMAT)
except ValueError as exc:
raise ValueError(f"バックアップ日時が{BACKUP_DATE_FORMAT}形式ではありません: {value!r}") from exc
try:
from zoneinfo import ZoneInfo
return naive.replace(tzinfo=ZoneInfo(timezone_name))
except Exception as exc:
raise ValueError(f"タイムゾーン {timezone_name!r} を利用できません: {exc}") from exc
def write_report(path: Path, rows: Iterable[dict[str, str]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"status", "action", "obsidian_path", "evernote_title", "desired_created_at",
"desired_updated_at", "current_created_at", "current_updated_at", "detail",
]
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def write_backup(path: Path, rows: Iterable[dict[str, str]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"obsidian_path", "original_created_at", "original_updated_at",
"evernote_title", "new_created_at", "new_updated_at",
]
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def report_row(
result: MatchResult,
current_created_at: str = "",
current_updated_at: str = "",
action: str = "",
detail: str | None = None,
) -> dict[str, str]:
note, evernote = result.note, result.evernote
return {
"status": result.status,
"action": action or result.action,
"obsidian_path": str(note.relative_path) if note else "",
"evernote_title": evernote.title if evernote else "",
"desired_created_at": evernote.created_at if evernote else "",
"desired_updated_at": evernote.updated_at if evernote else "",
"current_created_at": current_created_at,
"current_updated_at": current_updated_at,
"detail": detail if detail is not None else result.detail,
}
def run_restore(args: argparse.Namespace, vault_root: Path) -> int:
if not args.apply:
print("エラー: 復元を実行するには--applyが必要です。", file=sys.stderr)
return 2
if not args.restore_backup.is_file():
print(f"エラー: 日時バックアップCSVが見つかりません: {args.restore_backup}", file=sys.stderr)
return 2
if os.name != "nt":
print("エラー: 実ファイル日時の復元はWindowsでのみ実行できます。", file=sys.stderr)
return 2
try:
rows = read_backup_rows(args.restore_backup)
except ValueError as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 2
restore_report_rows: list[dict[str, str]] = []
restored = 0
for row in rows:
relative = Path(row["obsidian_path"])
path = (vault_root / relative).resolve()
if not is_within(path, vault_root) or not path.is_file():
restore_report_rows.append({
"status": "restore_skipped", "action": "skipped", "obsidian_path": row["obsidian_path"],
"evernote_title": row.get("evernote_title", ""), "desired_created_at": "",
"desired_updated_at": "", "current_created_at": "", "current_updated_at": "",
"detail": "Vault内に復元対象ファイルが見つかりません。",
})
continue
try:
created = parse_backup_datetime(row["original_created_at"], args.timezone)
updated = parse_backup_datetime(row["original_updated_at"], args.timezone)
set_windows_file_times(path, created, updated)
except (OSError, ValueError) as exc:
restore_report_rows.append({
"status": "restore_error", "action": "not_restored", "obsidian_path": row["obsidian_path"],
"evernote_title": row.get("evernote_title", ""), "desired_created_at": "",
"desired_updated_at": "", "current_created_at": "", "current_updated_at": "",
"detail": str(exc),
})
continue
restored += 1
restore_report_rows.append({
"status": "restored", "action": "restore_file_times", "obsidian_path": row["obsidian_path"],
"evernote_title": row.get("evernote_title", ""), "desired_created_at": row["original_created_at"],
"desired_updated_at": row["original_updated_at"], "current_created_at": "", "current_updated_at": "",
"detail": "日時バックアップCSVの値へ復元しました。",
})
write_report(args.report, restore_report_rows)
print(f"復元レポートを作成しました: {args.report}")
print(f"復元したノート: {restored}件 / バックアップCSV: {args.restore_backup}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="Evernote CSVを基に、Windows上のObsidian Markdownファイルの作成日時・更新日時を設定します。"
)
parser.add_argument("csv_file", type=Path, help="evernote_enex_to_csv.pyが出力したCSV(復元時は参照しないが、同じコマンド形式で指定する)")
parser.add_argument("vault", type=Path, help="Obsidian保管庫(Vault)のフォルダー")
parser.add_argument("--created-column", default="created_at_local", help="CSV上の作成日時列(既定: created_at_local)")
parser.add_argument("--updated-column", default="updated_at_local", help="CSV上の更新日時列(既定: updated_at_local)")
parser.add_argument("--no-updated", action="store_true", help="作成日時だけを設定し、実ファイルの更新日時は変更しない。")
parser.add_argument("--timezone", default="Asia/Tokyo", help="CSV日時のタイムゾーン(既定: Asia/Tokyo)")
parser.add_argument(
"--title-source", choices=["stem", "h1", "either"], default="either",
help="照合に使うObsidianタイトル。stem=ファイル名、h1=最初の#見出し、either=両方(既定)"
)
parser.add_argument(
"--include-dir", action="append", default=[], metavar="VAULT内の相対パス",
help="処理対象にするVault内フォルダー。複数回指定可。--apply時は少なくとも1つ必須。"
)
parser.add_argument(
"--exclude-dir", action="append", default=[], metavar="VAULT内の相対パス",
help="走査・更新から除外するVault内フォルダー。複数回指定可。"
)
parser.add_argument("--report", type=Path, default=Path("evernote_file_times_report.csv"), help="照合・更新レポートCSVの保存先")
parser.add_argument(
"--timestamp-backup", type=Path,
help="更新前の実ファイル日時を保存するCSV。--apply時は既定名で自動作成する。"
)
parser.add_argument("--apply", action="store_true", help="一意に一致したノートの実ファイル日時を変更する。指定しない場合は照合のみ。")
parser.add_argument("--restore-backup", type=Path, help="指定した日時バックアップCSVから実ファイル日時を復元する。CSV照合は行わない。")
args = parser.parse_args()
if not args.vault.is_dir():
print(f"エラー: Obsidian保管庫フォルダーが見つかりません: {args.vault}", file=sys.stderr)
return 2
vault_root = args.vault.resolve()
if args.restore_backup:
return run_restore(args, vault_root)
if not args.csv_file.is_file():
print("エラー: ENEX抽出CSVが見つかりません。", file=sys.stderr)
return 2
if args.apply and not args.include_dir:
print("エラー: --apply時は--include-dirで処理対象フォルダーを明示してください。", file=sys.stderr)
return 2
if args.apply and os.name != "nt":
print("エラー: 実ファイル日時の変更はWindowsでのみ実行できます。", file=sys.stderr)
return 2
try:
evernote_rows = read_evernote_csv(args.csv_file, args.created_column, args.updated_column, not args.no_updated)
include_dirs = resolve_vault_directories(args.vault, args.include_dir, "--include-dir")
exclude_dirs = resolve_vault_directories(args.vault, args.exclude_dir, "--exclude-dir")
# 日時形式とタイムゾーンを、更新前に全件検証する。
for row in evernote_rows:
parse_local_datetime(row.created_at, args.timezone)
if not args.no_updated and row.updated_at:
parse_local_datetime(row.updated_at, args.timezone)
except ValueError as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 2
if not evernote_rows:
print("エラー: 有効なEvernoteノートがCSVにありません。", file=sys.stderr)
return 2
exclude_dirs.append(vault_root / ".obsidian")
notes = read_markdown_notes(args.vault, args.title_source, include_dirs, exclude_dirs)
results = prepare_matches(evernote_rows, notes)
matched = [item for item in results if item.status == "matched" and item.note and item.evernote]
if args.apply:
backup_path = args.timestamp_backup or Path(
f"evernote_file_times_backup_{datetime.now():%Y%m%d_%H%M%S}.csv"
)
if backup_path.exists():
print(f"エラー: 日時バックアップCSVが既に存在します: {backup_path}", file=sys.stderr)
return 2
else:
backup_path = None
report_rows: list[dict[str, str]] = []
backup_rows: list[dict[str, str]] = []
for result in results:
if result.status != "matched" or not result.note or not result.evernote:
report_rows.append(report_row(result))
continue
try:
current_created, current_updated = read_current_file_times(result.note.path, args.timezone)
except OSError as exc:
report_rows.append(report_row(result, action="not_read", detail=f"ファイル日時を読めません: {exc}"))
continue
desired_updated = "" if args.no_updated else result.evernote.updated_at
action = "would_set_creation_and_modified" if desired_updated else "would_set_creation"
report_rows.append(report_row(result, current_created, current_updated, action, result.detail))
backup_rows.append({
"obsidian_path": str(result.note.relative_path),
"original_created_at": current_created,
"original_updated_at": current_updated,
"evernote_title": result.evernote.title,
"new_created_at": result.evernote.created_at,
"new_updated_at": desired_updated,
})
if not args.apply:
write_report(args.report, report_rows)
print(f"照合レポートを作成しました: {args.report}")
print(f"対象フォルダー: {', '.join(str(path.relative_to(vault_root)) or '.' for path in include_dirs) if include_dirs else 'Vault全体(照合モードのみ)'}")
print(f"Obsidian Markdown: {len(notes)}件 / Evernote CSV: {len(evernote_rows)}件")
print(f"実ファイル日時の更新候補: {len(backup_rows)}件")
print("ファイル日時は変更していません。レポートを確認後、--applyを付けて再実行してください。")
return 0
assert backup_path is not None
# 更新前の日時を全件保存してから、1件ずつ変更する。
write_backup(backup_path, backup_rows)
applied = 0
backup_by_path = {row["obsidian_path"]: row for row in backup_rows}
for row in report_rows:
if row["status"] != "matched" or row["obsidian_path"] not in backup_by_path:
continue
path = (vault_root / row["obsidian_path"]).resolve()
try:
created_at = parse_local_datetime(row["desired_created_at"], args.timezone)
updated_at = parse_local_datetime(row["desired_updated_at"], args.timezone) if row["desired_updated_at"] else None
set_windows_file_times(path, created_at, updated_at)
except (OSError, ValueError) as exc:
row["status"] = "file_time_write_error"
row["action"] = "not_updated"
row["detail"] = f"実ファイル日時を変更できません: {exc}"
continue
row["action"] = "set_creation_and_modified" if updated_at else "set_creation"
row["detail"] = "Windowsの実ファイル日時をEvernote日時へ設定しました。"
applied += 1
write_report(args.report, report_rows)
print(f"更新レポートを作成しました: {args.report}")
print(f"更新前の日時バックアップCSV: {backup_path}")
print(f"実ファイル日時を更新したノート: {applied}件")
return 0
if __name__ == "__main__":
raise SystemExit(main())
まとめ
EvernoteからObsidianへの移行で日時を保つには、ENEXからタイトル・作成日時・更新日時を取り出し、ObsidianのYAMLとWindows実ファイル日時を分けて扱うと整理しやすくなります。
- YAMLの
created・updatedは、Obsidianのプロパティやクエリーで使える。 - Windows実ファイル日時を設定すれば、ファイル日時を基準とするソートやエクスプローラー表示でも時系列を維持できる。
- タイトルが一意に一致するノートだけを更新し、重複・未一致はレポートで確認することで誤更新を避けられる。
-
--apply前の照合レポートと、更新前のバックアップCSVを必ず残す。
この記事が、ノート移行時のメタデータ保持の参考になれば幸いです。