Closed7

NotionからObsidianへ移行する

swatswat

NotionからObsidianへ

notionからobsidianへデータの移行作業メモ

移行する動機

  • AIとメモを連携させたかったけど、notion AIだと満足できなかった
  • ツェッテルカステンを参考にメモを管理することは前からやっていて、Obsidianに興味はあった
  • Obsidianでプレーンテキストで管理して、AIと連携してつかうことを考えた
  • ...と考えて移行作業をしてたら Notion MCPが正式リリースされたので、そっちでもよかったかもしれない
  • でも移行作業しちゃったので、このままObsidianを使ってみる
swatswat

Notionから移行する懸念点

環境

  • Notion内の記事数は3000ぐらい
  • Mac, Androidで利用したい

調査

懸念点

swatswat

Notion -> Obsidian 移行ツール

調査

公式のツールを使う

Import from Notion - Obsidian Help

エラー対応

  • obsidianでcreated_atがinvalid
    • Notionで日本語の設定のまま日付のメタデータを出力したら、invalidになった
    • Notionの設定を英語にして出力したら正常に表示された
  • 何個か取り込みエラーになった記事があった
    • エラーメッセージを全文見る方法がわからず、ページ名が見切れていて不明
    • 移行できなかったページは一旦無視する
swatswat

端末間同期

調査

RemotlySaveを使ってみる

mac, android間で無料で同期しようと思うとRemotlySaveが良さそうなので使ってみる

dropboxで同期してみる

onedriveに変えてみる

  • dropboxよりはレート制限が緩そう
  • こちらも絵文字制限はありそう

デバッグ方法

  • 開発者ツールを表示させるとエラーの詳細が見える
    • 他にいい方法ある?
  • 429エラーはレート制限なので、時間置いて再度実行
    • 5分ごとに同期設定にして放置する
  • 400エラーは対象ページに不正ななにかが含まれている
    • タイトルに絵文字などが入っている
    • 拡張子がないファイルもエラーになった
      • obsidian上で表示されてない同フォルダ内にあるファイルも同期されるので注意

タイトルの絵文字を削除するpythonスクリプト(生成AI製)

# ファイル名のお絵文字を削除する
import emoji
import glob
import os

# 絵文字リスト
emojis = emoji.UNICODE_EMOJI

# 指定フォルダから、全てのアイテムをリストアップ
path = './path/to/dir/'
allItems = glob.glob(path+'**', recursive=True)
allItems.sort()

# 絵文字が含まれていれば、それを削除する関数
def renameItems(items):

    for i in items:
        renamed = ''
        is_emoji = False

        for j in i:
            if j not in emojis['en']:
                renamed = renamed + j
            else:
              is_emoji=True

        if not is_emoji:
            continue

        try:
            print(i)
            print(renamed)
            print("")
            os.rename(i, renamed)
        except FileNotFoundError:
            return 1
    return 0

# 関数の実行
x = renameItems(allItems)
if x == 1:
    print('もう一度実行してください')
else:
    print('完了')

長いファイル名を切り落とすpythonスクリプト(生成AI製)

import os

# Obsidianのノートファイル名を一定の長さで切り落とす関数
def truncate_obsidian_file_names(vault_path, max_length):
    # 指定されたディレクトリ内のすべてのファイルを取得
    for root, dirs, files in os.walk(vault_path):
        for file in files:
            # print(file)
            # Markdownファイルのみを処理
            if file.endswith('.md'):
                file_path = os.path.join(root, file)

                # ファイル名と拡張子を分離
                name, ext = os.path.splitext(file)

                # ファイル名が指定された長さを超える場合
                if len(name) > max_length:
                    # 新しいファイル名を作成(拡張子は保持)
                    truncated_name = name[:max_length] + ext
                    new_file_path = os.path.join(root, truncated_name)

                    # ファイル名変更
                    os.rename(file_path, new_file_path)
                    print(f"名前変更: {file}{truncated_name}")

# 使用例
# Obsidianのvaultパスを指定
vault_path = "./path/to/dir/"
# 最大ファイル名長を指定(拡張子を除く)
max_length = 100
# 関数を実行
truncate_obsidian_file_names(vault_path, max_length)

swatswat

Obsidianを整理していく

プロパティを整理する

Notionで使っていたプロパティを整理する

不要な画像の整理

違うノートの同じ画像が重複して保存されるので、重複を排除したい

ハッシュで同一画像を判別し、ファイル名が短い方を採用し、片方の画像へのリンクをファイル名が短い方にリネームする(AIで出したコード、動作ちょっとおかしいかも)

# 重複した画像を削除し、リンク元を書き換える
import os
import re
from PIL import Image
import numpy as np
import shutil

# Obsidianのvaultのルートディレクトリを指定
VAULT_ROOT = "./"
# 画像が保存されているディレクトリ(通常は'attachments'や'images'など)
IMAGE_DIR = os.path.join(VAULT_ROOT, "Attachments")
# マークダウンファイルが保存されているディレクトリ
MARKDOWN_DIR = VAULT_ROOT

def calculate_image_hash(file_path):
    """画像のハッシュを計算する関数"""
    try:
        img = Image.open(file_path)
        # 画像をNumPy配列に変換
        img_array = np.array(img)
        return img_array
    except Exception as e:
        print(f"画像の読み込みエラー: {file_path} - {e}")
        return None

def find_duplicate_images():
    """重複画像を見つける関数"""
    image_dict = {}  # ハッシュ値をキー、ファイルパスのリストを値とする辞書
    duplicates = []  # 重複画像のペアを格納するリスト

    # 画像ファイルを検索
    for root, _, files in os.walk(IMAGE_DIR):
        for file in files:
            if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
                file_path = os.path.join(root, file)
                img_array = calculate_image_hash(file_path)

                if img_array is not None:
                    # NumPy配列をバイト列に変換してハッシュ化
                    img_hash = hash(img_array.tobytes())

                    if img_hash in image_dict:
                        image_dict[img_hash].append(file_path)
                    else:
                        image_dict[img_hash] = [file_path]

    # 重複画像を見つける
    for img_hash, file_paths in image_dict.items():
        if len(file_paths) > 1:
            # ファイル名の長さでソート
            file_paths.sort(key=lambda x: len(os.path.basename(x)))
            # 最初の要素(ファイル名が最も短いもの)を保持し、残りを重複として扱う
            keep_file = file_paths[0]
            for duplicate in file_paths[1:]:
                duplicates.append((keep_file, duplicate))

    return duplicates

def update_markdown_links(keep_file, duplicate_file):
    """マークダウンファイル内のリンクを更新する関数"""
    keep_filename = os.path.basename(keep_file)
    duplicate_filename = os.path.basename(duplicate_file)

    # マークダウンファイルを検索
    for root, _, files in os.walk(MARKDOWN_DIR):
        for file in files:
            if file.lower().endswith('.md'):
                file_path = os.path.join(root, file)
                try:
                    with open(file_path, 'r', encoding='utf-8') as f:
                        content = f.read()

                    # 重複画像へのリンクを検索して置換
                    # Markdownのリンクパターン: ![alt text](path/to/image)
                    pattern = r'!\[.*?\]\(.*?' + re.escape(duplicate_filename) + r'(?:\s+".*?")?\)'
                    replacement = lambda m: m.group(0).replace(duplicate_filename, keep_filename)
                    new_content = re.sub(pattern, replacement, content)

                    # Obsidian形式のリンクパターン: ![[path/to/image]]
                    pattern = r'!\[\[.*?' + re.escape(duplicate_filename) + r'.*?\]\]'
                    replacement = lambda m: m.group(0).replace(duplicate_filename, keep_filename)
                    new_content = re.sub(pattern, replacement, new_content)

                    # 変更があれば保存
                    if new_content != content:
                      with open(file_path, 'w', encoding='utf-8') as f:
                          f.write(new_content)
                      print(f"更新されたファイル: {file_path}")

                except Exception as e:
                    print(f"ファイル処理エラー: {file_path} - {e}")

def main():
    """メイン関数"""
    print("重複画像を検索中...")
    duplicates = find_duplicate_images()

    if not duplicates:
        print("重複画像は見つかりませんでした。")
        return

    print(f"{len(duplicates)}個の重複画像が見つかりました。")

    for keep_file, duplicate_file in duplicates:
        print(f"保持: {keep_file}")
        print(f"削除: {duplicate_file}")

        # マークダウンファイル内のリンクを更新
        update_markdown_links(keep_file, duplicate_file)

        # 重複画像を削除
        try:
            os.remove(duplicate_file)
            print(f"削除完了: {duplicate_file}")
        except Exception as e:
            print(f"削除エラー: {duplicate_file} - {e}")

    print("処理が完了しました。")

if __name__ == "__main__":
    main()

壊れたリンクの整理

壊れたリンクを探してくれるプラグインを使う

空白が大文字と小文字で違ってインポートされていて、リンクが切れていたものを修正する(AIで出したコード)

import re
import os
import glob

def extract_broken_links(broken_links_file):
    """ブロークンリンクファイルからリンクを抽出する"""
    broken_links = []

    with open(broken_links_file, 'r', encoding='utf-8') as f:
        content = f.read()

    # リンクパターンを抽出
    # "in [[ファイル名]]" の形式を探す
    pattern = r'- \[\[(.*?)\]\] in \[\[(.*?)\]\]'
    matches = re.findall(pattern, content)

    for broken_link, parent_file in matches:
        broken_links.append((broken_link, parent_file))

    return broken_links

def find_correct_filenames(vault_path):
    """Obsidianボールト内の全ファイル名を取得"""
    all_files = []

    # マークダウンファイルを再帰的に検索
    for filename in glob.glob(f"{vault_path}/**/*.md", recursive=True):
        # ファイル名のみを抽出(パスなし)
        basename = os.path.basename(filename)
        # 拡張子を除去
        name_without_ext = os.path.splitext(basename)[0]
        all_files.append(name_without_ext)

    return all_files

def find_best_match(broken_link, all_filenames):
    """ブロークンリンクに最も近いファイル名を見つける"""
    # 完全一致があれば返す
    if broken_link in all_filenames:
        return broken_link

    # スペースの違いを無視して比較
    for filename in all_filenames:
        if re.sub(r'\s+', ' ', broken_link) == re.sub(r'\s+', ' ', filename):
            return filename

        # スペースの数が違うだけの場合
        if broken_link.replace(' ', '') == filename.replace(' ', ''):
            return filename

    return None

def fix_broken_links(vault_path, broken_links_file, output_file):
    """ブロークンリンクを修正する"""
    broken_links = extract_broken_links(broken_links_file)
    all_filenames = find_correct_filenames(vault_path)

    fixes = []

    for broken_link, parent_file in broken_links:
        correct_link = find_best_match(broken_link, all_filenames)

        if correct_link and correct_link != broken_link:
            fixes.append({
                'parent_file': parent_file,
                'broken_link': broken_link,
                'correct_link': correct_link
            })

    # 修正内容をファイルに書き出す
    with open(output_file, 'w', encoding='utf-8') as f:
        f.write("# Obsidian リンク修正レポート\n\n")

        if not fixes:
            f.write("修正が必要なリンクは見つかりませんでした。\n")
        else:
            f.write("## 修正が必要なリンク\n\n")

            for fix in fixes:
                f.write(f"### ファイル: {fix['parent_file']}\n")
                f.write(f"- 誤: [[{fix['broken_link']}]]\n")
                f.write(f"- 正: [[{fix['correct_link']}]]\n\n")

    return fixes

def apply_fixes(vault_path, fixes):
    """修正を実際のファイルに適用する"""
    fixed_count = 0

    for fix in fixes:
        parent_file_pattern = f"{vault_path}/**/*{fix['parent_file']}*.md"
        parent_files = glob.glob(parent_file_pattern, recursive=True)

        for file_path in parent_files:
            with open(file_path, 'r', encoding='utf-8') as f:
                content = f.read()

            # リンクを修正
            pattern = r'\[\[' + re.escape(fix['broken_link']) + r'\]\]'
            replacement = f"[[{fix['correct_link']}]]"
            new_content = re.sub(pattern, replacement, content)

            if new_content != content:
                with open(file_path, 'w', encoding='utf-8') as f:
                    f.write(new_content)
                fixed_count += 1

    return fixed_count

def main():
    # 設定
    vault_path = "./"  # Obsidianボールトのパスを指定
    broken_links_file = "./broken links output.md"  # ブロークンリンクファイル
    output_file = "link_fixes_report.md"  # 修正レポートファイル

    # 修正内容を生成
    fixes = fix_broken_links(vault_path, broken_links_file, output_file)

    print(f"{len(fixes)}個の修正候補が見つかりました。詳細は{output_file}を確認してください。")

    # 修正を適用するか確認
    apply_fix = input("修正を適用しますか?(y/n): ")

    if apply_fix.lower() == 'y':
        fixed_count = apply_fixes(vault_path, fixes)
        print(f"{fixed_count}個のファイルが修正されました。")
    else:
        print("修正は適用されませんでした。")

if __name__ == "__main__":
    main()

Notionのギャラリービューを再現する

読書メモは書影が見えるページが欲しい
dataviewやCSSを駆使すれば作れるっぽいがもっと手軽に作りたい
プラグインを使う

swatswat

Obsidian運用方針

swatswat

Obsidianに移行した感想

  • レスポンスが早く、きびきび動いていい感じ
  • ノートを一括で編集するときに独自でスクリプト書いて修正できるのは自由度高くてよい
  • Excalidrawが使えるのをしらなかったが、見た目が良くて好き
このスクラップは5ヶ月前にクローズされました