OpenOfficeマイグレーションアプリ

<PRE>
# -*- coding: utf-8 -*-
"""
OpenOffice Cドライブ -> Eドライブ マイグレーションツール 完全修正版

推奨方式:
  C:\Program Files\OpenOffice 4 の実体を E:\OpenOffice 4 にコピー
  その後、C側にジャンクションを作成する

これにより、レジストリや既存ショートカットが C:\Program Files\OpenOffice 4 を指していても、
実体は E:\OpenOffice 4 に置かれるため、失敗しにくい。

実行条件:
  - Windows
  - 管理者権限
  - OpenOfficeを終了しておく
  - pywin32 は任意。なくても動くが、ショートカット更新はスキップされる。
"""

import ctypes
import datetime
import os
import queue
import shutil
import subprocess
import sys
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

try:
    import winreg
except ImportError:
    winreg = None

try:
    import win32com.client
except ImportError:
    win32com = None


APP_TITLE = "OpenOffice マイグレーションツール 完全修正版"


def is_windows():
    return os.name == "nt"


def is_admin():
    if not is_windows():
        return False
    try:
        return bool(ctypes.windll.shell32.IsUserAnAdmin())
    except Exception:
        return False


def norm_path(path):
    return os.path.normcase(os.path.abspath(os.path.expandvars(os.path.expanduser(path.strip()))))


def is_junction_or_symlink(path):
    return os.path.exists(path) and os.path.islink(path)


def run_cmd(cmd):
    completed = subprocess.run(
        cmd,
        shell=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="cp932",
        errors="replace",
    )
    return completed.returncode, completed.stdout, completed.stderr


def process_exists(names):
    """
    names例: ["soffice.exe", "soffice.bin"]
    """
    try:
        code, out, err = run_cmd("tasklist")
        if code != 0:
            return False
        lower = out.lower()
        return any(name.lower() in lower for name in names)
    except Exception:
        return False


def get_tree_stats(path):
    """
    フォルダ内のファイル数・合計サイズを返す
    """
    file_count = 0
    total_size = 0

    for root, dirs, files in os.walk(path):
        for name in files:
            full = os.path.join(root, name)
            try:
                file_count += 1
                total_size += os.path.getsize(full)
            except OSError:
                pass

    return file_count, total_size


def safe_makedirs(path):
    os.makedirs(path, exist_ok=True)


def copy_tree_with_progress(src, dst, log_func, progress_func):
    """
    shutil.copytreeだけだと進捗が出ず、失敗箇所も分かりにくいので手動コピーする。
    """
    src = norm_path(src)
    dst = norm_path(dst)

    total_files, total_size = get_tree_stats(src)
    if total_files == 0:
        raise RuntimeError("移動元フォルダにコピー対象ファイルがありません。OpenOfficeのパスが正しいか確認してください。")

    copied_files = 0
    copied_size = 0

    log_func(f"コピー対象: {total_files} ファイル / {total_size:,} bytes")

    for root, dirs, files in os.walk(src):
        rel = os.path.relpath(root, src)
        target_root = dst if rel == "." else os.path.join(dst, rel)

        safe_makedirs(target_root)

        for d in dirs:
            safe_makedirs(os.path.join(target_root, d))

        for name in files:
            src_file = os.path.join(root, name)
            dst_file = os.path.join(target_root, name)

            try:
                safe_makedirs(os.path.dirname(dst_file))
                shutil.copy2(src_file, dst_file)

                copied_files += 1
                try:
                    copied_size += os.path.getsize(src_file)
                except OSError:
                    pass

                if copied_files % 20 == 0 or copied_files == total_files:
                    percent = 10 + int((copied_files / total_files) * 50)
                    progress_func(percent)
                    log_func(f"コピー中: {copied_files}/{total_files} files")

            except Exception as e:
                raise RuntimeError(
                    f"コピーに失敗しました。\n"
                    f"元: {src_file}\n"
                    f"先: {dst_file}\n"
                    f"理由: {e}"
                )

    return total_files, total_size, copied_files, copied_size


def create_junction(link_path, target_path, log_func):
    """
    mklink /J "C:\\Program Files\\OpenOffice 4" "E:\\OpenOffice 4"
    """
    link_path = norm_path(link_path)
    target_path = norm_path(target_path)

    if os.path.exists(link_path):
        raise RuntimeError(f"ジャンクション作成先が既に存在します: {link_path}")

    cmd = f'mklink /J "{link_path}" "{target_path}"'
    log_func(f"ジャンクション作成: {cmd}")

    code, out, err = run_cmd(f'cmd /c {cmd}')

    if code != 0:
        raise RuntimeError(
            "ジャンクション作成に失敗しました。\n"
            f"command: {cmd}\n"
            f"stdout:\n{out}\n"
            f"stderr:\n{err}"
        )

    log_func(out.strip())

    if not os.path.exists(link_path):
        raise RuntimeError("mklinkは成功したように見えますが、ジャンクションが確認できません。")

    return True


def backup_registry(log_func):
    """
    念のためOpenOffice関連レジストリを .reg にバックアップする。
    失敗しても致命傷にはしない。
    """
    backup_dir = os.path.join(os.path.expanduser("~"), "Desktop", "OpenOffice_migration_registry_backup")
    safe_makedirs(backup_dir)

    ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")

    targets = [
        (r"HKLM\SOFTWARE\OpenOffice.org", f"HKLM_OpenOffice_{ts}.reg"),
        (r"HKLM\SOFTWARE\WOW6432Node\OpenOffice.org", f"HKLM_WOW6432Node_OpenOffice_{ts}.reg"),
        (r"HKCU\Software\OpenOffice.org", f"HKCU_OpenOffice_{ts}.reg"),
    ]

    for key, filename in targets:
        out_file = os.path.join(backup_dir, filename)
        cmd = f'reg export "{key}" "{out_file}" /y'
        code, out, err = run_cmd(cmd)
        if code == 0:
            log_func(f"レジストリバックアップ作成: {out_file}")
        else:
            log_func(f"レジストリバックアップ対象なし又は失敗: {key}")


def replace_registry_strings(root, subkey, old, new, log_func):
    """
    指定レジストリ配下を再帰的に見て、REG_SZ / REG_EXPAND_SZ 内の old を new に置換する。
    ジャンクション方式では必須ではないため、オプション扱い。
    """
    if winreg is None:
        log_func("winregが使えないため、レジストリ更新をスキップしました。")
        return 0

    changed = 0

    try:
        key = winreg.OpenKey(root, subkey, 0, winreg.KEY_READ | winreg.KEY_WRITE)
    except FileNotFoundError:
        return 0
    except PermissionError:
        log_func(f"レジストリ権限なし: {subkey}")
        return 0
    except OSError as e:
        log_func(f"レジストリを開けません: {subkey} / {e}")
        return 0

    try:
        value_count = winreg.QueryInfoKey(key)[1]

        for i in range(value_count):
            try:
                name, data, typ = winreg.EnumValue(key, i)

                if typ in (winreg.REG_SZ, winreg.REG_EXPAND_SZ) and isinstance(data, str):
                    if old.lower() in data.lower():
                        replaced = data.replace(old, new)

                        # 大文字小文字違い対策
                        if replaced == data:
                            replaced = data.replace(old.replace("\\", "\\\\"), new.replace("\\", "\\\\"))

                        if replaced != data:
                            winreg.SetValueEx(key, name, 0, typ, replaced)
                            changed += 1
                            log_func(f"Registry更新: {subkey}\\{name}")

            except OSError:
                continue

        subkey_count = winreg.QueryInfoKey(key)[0]
        subkeys = []

        for i in range(subkey_count):
            try:
                child = winreg.EnumKey(key, i)
                subkeys.append(child)
            except OSError:
                pass

    finally:
        winreg.CloseKey(key)

    for child in subkeys:
        changed += replace_registry_strings(root, subkey + "\\" + child, old, new, log_func)

    return changed


def update_openoffice_registry_paths(old, new, log_func):
    """
    OpenOffice関連だけ置換。
    """
    if winreg is None:
        return

    roots = [
        (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\OpenOffice.org"),
        (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\OpenOffice.org"),
        (winreg.HKEY_CURRENT_USER, r"Software\OpenOffice.org"),
    ]

    total = 0
    for root, subkey in roots:
        total += replace_registry_strings(root, subkey, old, new, log_func)

    log_func(f"レジストリ置換完了: {total} 箇所")


def update_shortcuts(old, new, log_func):
    """
    .lnkショートカットのリンク先と作業フォルダを置換する。
    pywin32 がない場合はスキップ。
    """
    if win32com is None:
        log_func("pywin32が無いため、ショートカット更新はスキップします。")
        log_func("必要なら後で: pip install pywin32")
        return 0

    shell = win32com.client.Dispatch("WScript.Shell")

    candidates = [
        os.path.join(os.path.expanduser("~"), "Desktop"),
        os.path.join(os.path.expanduser("~"), "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs"),
        os.path.join(os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop"),
        r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs",
    ]

    changed = 0
    old_norm = old.lower().replace("/", "\\")
    new_norm = new.replace("/", "\\")

    for base in candidates:
        if not os.path.isdir(base):
            continue

        for root, dirs, files in os.walk(base):
            for name in files:
                if not name.lower().endswith(".lnk"):
                    continue

                lnk = os.path.join(root, name)

                try:
                    sc = shell.CreateShortcut(lnk)

                    target = sc.Targetpath or ""
                    workdir = sc.WorkingDirectory or ""

                    updated = False

                    if target.lower().replace("/", "\\").startswith(old_norm):
                        sc.Targetpath = target.replace(old, new_norm)
                        updated = True

                    if workdir.lower().replace("/", "\\").startswith(old_norm):
                        sc.WorkingDirectory = workdir.replace(old, new_norm)
                        updated = True

                    if updated:
                        sc.Save()
                        changed += 1
                        log_func(f"ショートカット更新: {lnk}")

                except Exception as e:
                    log_func(f"ショートカット更新失敗: {lnk} / {e}")

    log_func(f"ショートカット更新完了: {changed} 件")
    return changed


class OpenOfficeMigratorApp:
    def __init__(self, root):
        self.root = root
        self.root.title(APP_TITLE)
        self.root.geometry("760x620")

        self.queue = queue.Queue()
        self.worker = None

        self.source_var = tk.StringVar(value=r"C:\Program Files\OpenOffice 4")
        self.dest_var = tk.StringVar(value=r"E:\OpenOffice 4")

        self.keep_backup_var = tk.BooleanVar(value=True)
        self.update_shortcuts_var = tk.BooleanVar(value=True)
        self.update_registry_var = tk.BooleanVar(value=False)
        self.allow_existing_dest_var = tk.BooleanVar(value=True)

        self.status_var = tk.StringVar(value="準備完了")

        self.build_ui()
        self.root.after(100, self.poll_queue)

    def build_ui(self):
        main = ttk.Frame(self.root)
        main.pack(fill="both", expand=True, padx=12, pady=12)

        frame_config = ttk.LabelFrame(main, text="設定")
        frame_config.pack(fill="x", pady=6)

        frame_config.columnconfigure(1, weight=1)

        ttk.Label(frame_config, text="移動元 OpenOffice フォルダ").grid(row=0, column=0, sticky="w", padx=8, pady=6)
        ttk.Entry(frame_config, textvariable=self.source_var).grid(row=0, column=1, sticky="ew", padx=8, pady=6)
        ttk.Button(frame_config, text="参照", command=self.browse_source).grid(row=0, column=2, padx=8, pady=6)

        ttk.Label(frame_config, text="移動先 Eドライブ フォルダ").grid(row=1, column=0, sticky="w", padx=8, pady=6)
        ttk.Entry(frame_config, textvariable=self.dest_var).grid(row=1, column=1, sticky="ew", padx=8, pady=6)
        ttk.Button(frame_config, text="参照", command=self.browse_dest).grid(row=1, column=2, padx=8, pady=6)

        ttk.Checkbutton(
            frame_config,
            text="移動先フォルダが既に存在しても利用する",
            variable=self.allow_existing_dest_var
        ).grid(row=2, column=0, columnspan=3, sticky="w", padx=8, pady=3)

        ttk.Checkbutton(
            frame_config,
            text="Cドライブ側の元フォルダをバックアップとして残す",
            variable=self.keep_backup_var
        ).grid(row=3, column=0, columnspan=3, sticky="w", padx=8, pady=3)

        ttk.Checkbutton(
            frame_config,
            text="ショートカットもEドライブ向けに更新する",
            variable=self.update_shortcuts_var
        ).grid(row=4, column=0, columnspan=3, sticky="w", padx=8, pady=3)

        ttk.Checkbutton(
            frame_config,
            text="OpenOffice関連レジストリ内のパスもEドライブ向けに置換する(通常は不要)",
            variable=self.update_registry_var
        ).grid(row=5, column=0, columnspan=3, sticky="w", padx=8, pady=3)

        frame_action = ttk.LabelFrame(main, text="実行")
        frame_action.pack(fill="x", pady=6)

        ttk.Label(frame_action, textvariable=self.status_var).pack(anchor="w", padx=8, pady=6)

        self.progress = ttk.Progressbar(frame_action, orient="horizontal", mode="determinate", maximum=100)
        self.progress.pack(fill="x", padx=8, pady=6)

        btns = ttk.Frame(frame_action)
        btns.pack(fill="x", padx=8, pady=8)

        self.btn_start = ttk.Button(btns, text="マイグレーション開始", command=self.start)
        self.btn_start.pack(side="left", padx=4)

        ttk.Button(btns, text="終了", command=self.root.destroy).pack(side="left", padx=4)

        frame_log = ttk.LabelFrame(main, text="ログ")
        frame_log.pack(fill="both", expand=True, pady=6)

        self.text_log = tk.Text(frame_log, height=18, wrap="word")
        self.text_log.pack(side="left", fill="both", expand=True)

        scroll = ttk.Scrollbar(frame_log, command=self.text_log.yview)
        scroll.pack(side="right", fill="y")
        self.text_log.configure(yscrollcommand=scroll.set)

        self.log("このツールは OpenOffice を Eドライブへコピーし、Cドライブ側にジャンクションを作成します。")
        self.log("必ず OpenOffice を終了し、管理者権限で実行してください。")

    def browse_source(self):
        path = filedialog.askdirectory(title="移動元 OpenOffice フォルダを選択")
        if path:
            self.source_var.set(path)

    def browse_dest(self):
        path = filedialog.askdirectory(title="移動先フォルダを選択")
        if path:
            self.dest_var.set(path)

    def log(self, msg):
        ts = datetime.datetime.now().strftime("%H:%M:%S")
        self.text_log.insert("end", f"[{ts}] {msg}\n")
        self.text_log.see("end")
        self.root.update_idletasks()

    def qlog(self, msg):
        self.queue.put(("log", msg))

    def qstatus(self, msg):
        self.queue.put(("status", msg))

    def qprogress(self, value):
        self.queue.put(("progress", value))

    def poll_queue(self):
        try:
            while True:
                kind, value = self.queue.get_nowait()

                if kind == "log":
                    self.log(value)
                elif kind == "status":
                    self.status_var.set(value)
                elif kind == "progress":
                    self.progress["value"] = value
                elif kind == "done":
                    self.btn_start.config(state="normal")
                    messagebox.showinfo("完了", value)
                elif kind == "error":
                    self.btn_start.config(state="normal")
                    messagebox.showerror("エラー", value)

        except queue.Empty:
            pass

        self.root.after(100, self.poll_queue)

    def start(self):
        if self.worker and self.worker.is_alive():
            messagebox.showwarning("実行中", "現在処理中です。")
            return

        source = norm_path(self.source_var.get())
        dest = norm_path(self.dest_var.get())

        if not is_windows():
            messagebox.showerror("エラー", "このツールはWindows専用です。")
            return

        if not is_admin():
            messagebox.showerror(
                "管理者権限が必要",
                "Program Files配下のリネームとジャンクション作成には管理者権限が必要です。\n\n"
                "PowerShellまたはコマンドプロンプトを「管理者として実行」し、そこから起動してください。"
            )
            return

        if process_exists(["soffice.exe", "soffice.bin", "swriter.exe", "scalc.exe"]):
            messagebox.showerror(
                "OpenOfficeが起動中",
                "OpenOffice関連プロセスが起動中です。\n"
                "Writer、Calc、Quickstarter等をすべて終了してから再実行してください。"
            )
            return

        if not os.path.isdir(source):
            messagebox.showerror("エラー", f"移動元フォルダが存在しません。\n{source}")
            return

        if os.path.abspath(source).lower() == os.path.abspath(dest).lower():
            messagebox.showerror("エラー", "移動元と移動先が同じです。")
            return

        if dest.lower().startswith(source.lower() + os.sep):
            messagebox.showerror("エラー", "移動先を移動元フォルダの中にすることはできません。")
            return

        if os.path.exists(dest) and not self.allow_existing_dest_var.get():
            messagebox.showerror(
                "エラー",
                f"移動先フォルダが既に存在します。\n{dest}\n\n"
                "既存フォルダを利用する場合はチェックを入れてください。"
            )
            return

        msg = (
            "以下の処理を実行します。\n\n"
            f"移動元: {source}\n"
            f"移動先: {dest}\n\n"
            "1. EドライブへOpenOfficeをコピー\n"
            "2. コピー検証\n"
            "3. Cドライブ側の元フォルダをバックアップ名に変更\n"
            "4. Cドライブ側にEドライブへのジャンクションを作成\n\n"
            "続行しますか?"
        )

        if not messagebox.askyesno("確認", msg):
            return

        self.btn_start.config(state="disabled")
        self.progress["value"] = 0
        self.text_log.delete("1.0", "end")

        self.worker = threading.Thread(target=self.do_migration, daemon=True)
        self.worker.start()

    def do_migration(self):
        source = norm_path(self.source_var.get())
        dest = norm_path(self.dest_var.get())

        try:
            self.qstatus("開始")
            self.qprogress(0)
            self.qlog("マイグレーション開始")

            self.qlog(f"移動元: {source}")
            self.qlog(f"移動先: {dest}")

            if is_junction_or_symlink(source):
                raise RuntimeError(
                    "移動元が既にジャンクションまたはシンボリックリンクです。\n"
                    "既に移行済みの可能性があります。"
                )

            self.qstatus("移動先フォルダを準備中")
            self.qprogress(5)
            safe_makedirs(dest)

            self.qstatus("レジストリバックアップ中")
            self.qprogress(8)
            backup_registry(self.qlog)

            self.qstatus("ファイルコピー中")
            copied = copy_tree_with_progress(source, dest, self.qlog, self.qprogress)

            src_files, src_size, copied_files, copied_size = copied

            self.qstatus("コピー検証中")
            self.qprogress(65)

            dst_files, dst_size = get_tree_stats(dest)

            self.qlog(f"移動元: {src_files} files / {src_size:,} bytes")
            self.qlog(f"移動先: {dst_files} files / {dst_size:,} bytes")

            if dst_files < src_files:
                raise RuntimeError(
                    "コピー後のファイル数が不足しています。\n"
                    f"移動元: {src_files}\n"
                    f"移動先: {dst_files}\n"
                    "コピーが不完全なため中止します。"
                )

            if dst_size < int(src_size * 0.98):
                raise RuntimeError(
                    "コピー後の合計サイズが不足しています。\n"
                    f"移動元: {src_size:,} bytes\n"
                    f"移動先: {dst_size:,} bytes\n"
                    "コピーが不完全な可能性が高いため中止します。"
                )

            self.qlog("コピー検証OK")

            ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
            backup_path = source.rstrip("\\/") + f"_backup_{ts}"

            self.qstatus("Cドライブ側の元フォルダをバックアップ名へ変更中")
            self.qprogress(72)

            if os.path.exists(backup_path):
                raise RuntimeError(f"バックアップ先が既に存在します: {backup_path}")

            self.qlog(f"元フォルダをリネーム: {source} -> {backup_path}")
            os.rename(source, backup_path)

            try:
                self.qstatus("ジャンクション作成中")
                self.qprogress(78)
                create_junction(source, dest, self.qlog)
            except Exception as e:
                self.qlog("ジャンクション作成に失敗したため、元フォルダを復元します。")
                if not os.path.exists(source):
                    os.rename(backup_path, source)
                raise e

            self.qprogress(85)
            self.qlog("ジャンクション作成OK")
            self.qlog(f"C側の見かけのパス: {source}")
            self.qlog(f"実体の保存先: {dest}")

            if self.update_registry_var.get():
                self.qstatus("レジストリ内パスを更新中")
                self.qprogress(88)
                update_openoffice_registry_paths(source, dest, self.qlog)
            else:
                self.qlog("レジストリ置換はスキップしました。ジャンクション方式なので通常は問題ありません。")

            if self.update_shortcuts_var.get():
                self.qstatus("ショートカット更新中")
                self.qprogress(92)
                update_shortcuts(source, dest, self.qlog)
            else:
                self.qlog("ショートカット更新はスキップしました。")

            if not self.keep_backup_var.get():
                self.qstatus("Cドライブ側バックアップを削除中")
                self.qprogress(96)
                self.qlog(f"バックアップ削除: {backup_path}")
                shutil.rmtree(backup_path, ignore_errors=False)
                self.qlog("バックアップ削除完了")
            else:
                self.qlog(f"Cドライブ側バックアップを残しました: {backup_path}")
                self.qlog("容量を本当に空けたい場合は、動作確認後にこのバックアップフォルダを手動削除してください。")

            self.qstatus("完了")
            self.qprogress(100)

            done_msg = (
                "OpenOfficeのマイグレーションが完了しました。\n\n"
                f"実体: {dest}\n"
                f"C側ジャンクション: {source}\n\n"
                "OpenOfficeが起動するか確認してください。"
            )
            self.queue.put(("done", done_msg))

        except Exception as e:
            self.qstatus("エラー")
            self.qlog(f"ERROR: {e}")
            self.queue.put(("error", str(e)))


def main():
    root = tk.Tk()
    app = OpenOfficeMigratorApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
</PRE>

コメント

このブログの人気の投稿

ミライアイ内服薬は薬事法違反で、ほとんど効果がない詐欺ですか?

最高裁での上告理由書受理・却下の判断基準について

裁判官の忌避申立書の作成例