物理の駅 Physics station by 現役研究者

テクノロジーは共有されてこそ栄える

SlackからエクスポートしたZIPファイル内のファイルパスの文字化けを修正する

いつからかSlackのチャンネル名に日本語が使えるようになったが、SlackからエクスポートしたZIPファイルのファイル名、ディレクトリ名はcp437 コードページ437 - Wikipedia でエンコードされているため、UTF-8にするには、cp437としてエンコードする必要がある。

(この現象は、Windowsだけかもしれん。他の環境では未確認)

Pythonの場合、ZIPファイルは自動的にcp437で開かれるので、ファイル名のみエンコードしてからutf-8でデコードする。

import zipfile

with zipfile.ZipFile(zipPath, 'r') as myzip:
    infolist = myzip.infolist()
    
    for info in infolist:
        filename_utf_8 = info.filename.encode('cp437').decode('utf-8')

C#の場合は、ZIPファイルを開く時にcp437を指定し、かつファイル名をcp437でエンコードしてからutf-8でデコードする。

using System.IO.Compression;
using System.Text;

namespace slack2csv
{
    class Class1
    {
        void ReadZip(string zipPath)
        {
            var za = ZipFile.Open(zipPath, ZipArchiveMode.Read, Encoding.GetEncoding("cp437"));
            foreach (var item in za.Entries)
            {
                var filename_utf_8 = Encoding.GetEncoding("utf-8").GetString(Encoding.GetEncoding("cp437").GetBytes(item.ToString()));
            }
        }
    }
}

参照:

www.lifewithpython.com

.gitignore: ディレクトリ内の一部のファイルだけ管理して他は無視する方法

ディレクトリを無視 /build/ と書くと、それ以降の全ての除外(!)は効かなくなるので注意せよ。

ディレクトリ内の一部のファイルだけ管理して他は無視するとき、さらに深いディレクトリにある一部のファイルだけ管理して他は無視するときの .gitignoreの記述方法

/build/* #build以下の全ファイルを無視
!/build/Makefile #Makefileファイルだけ管理

!/build/CMakeFiles/ #CMakeFilesディレクトリ以下は管理
/build/CMakeFiles/* #CMakeFilesディレクトリ以下の全ファイルを無視
!/build/CMakeFiles/Makefile2 #Makefile2ファイルだけ管理

!/build/CMakeFiles/gastable.dir/ #以下同
/build/CMakeFiles/gastable.dir/*
!/build/CMakeFiles/gastable.dir/build.make
!/build/CMakeFiles/gastable.dir/depend.make
!/build/CMakeFiles/gastable.dir/flags.make
!/build/CMakeFiles/gastable.dir/progress.make
!/build/CMakeFiles/gastable.dir/link.txt

未解決: Googleドキュメントで変換中の文節が分からない

GoogleドキュメントやGoogleスライドショーで日本語入力をしているとき、文字変換中に、変換対象の文節(部分)が分からない問題が発生している。

ハイライトは表示されない。アンダーライン太い+点線の区別もなく、アンダーラインの切れ目もない。これではとても入力しにくい。

Google IMEでも、Microsoft IMEでも、ATOK IMEでも再現する。ブラウザを変えても再現する。別のWindows機でも再現する。背景色や文字色を変えても再現する。思いつく限り試したが、どうにもならなかった。

解決策の分かる方いますか?

入力しにくいケース

Google ドキュメント + Chrome + Google IME

Google ドキュメント(黒背景、白文字) + Chrome + Google IME

Google ドキュメント + FireFox + Google IME

Google ドキュメント + FireFox + Microsoft IME

Google ドキュメント + Chrome + Microsoft IME

Google ドキュメント + EdgeのIEモード + Google IME

Googleドキュメント + Opera + Google IME

Google ドキュメント + Edge + ATOK IME

Googleスライドショー + Chrome + Google IME


入力しやすいケース

Googleドキュメント以外はどうか?

Microsoft Word + Google IME

OneDrive上のオンラインWord + Chrome + Google IME

テキストボックス + Chrome + Google IME

Chromeの検索バー + Google IME

Gmail + Google IME

Google スプレッドシート + Chrome + Google IME

Boxnote + Chrome + Google IME

Python: SlackにインポートするためのCSVファイルを、Slackのエクスポートしたデータ(ZIPファイル)から作成する

旧ワークスペースの一部のチャンネルのみ、新ワークスペース(新WS)にCSVファイルで移行するためのPythonスクリプトを書いたので紹介する。

準備することは、

  • 旧ワークスペースからエクスポートしたデータ (アーカイブしたZIPファイル)
  • ワークスペースのチャンネル名と新WSのチャンネル名 (新規作成でも可)
  • 旧ワークスペースのユーザーID、新WSのユーザー名とユーザーID (新WSに移行したい全ての人が参加している前提)
  • 各オプションのオンオフ

である。コードは以下の通り

# ZIPファイルへのPATH
filepath = r'Sample Slack export Jan 01 2020 - Jul 20 2022.zip'

# 出力するCSVファイル名
csvpath = 'ForImport.csv'

#旧ワークスペースのチャンネル名 → 新ワークスペースのチャンネル名
channel_mapping = {'生物実験':'new-生物実験',
                   '物理実験':'new-物理実験',
                   '装置':'new-装置'}

# 旧ワークスペースのユーザーID → [新ワークスペースのユーザー名・ユーザーID]
name_mapping = {'UWGKT5M5I': ['Hakase Shinonome','U01CKGMWGBM'], 
                'U01AC7YM2QW': ['Nichijo Isezaki','U01WG2KJB6D']}

show_file_link = True
show_thread_id = True
show_quote_post = True
ignore_not_user = False

変換コード

import os
import json
import zipfile

def get_text(l, name_mapping):
    text = l['text'].replace(r'"',r'\"') # デリミタに " を使うのでエスケープする
        
    # ファイルが添付された投稿の場合、旧WSのファイルへのリンクをFile: と書く
    if show_file_link and 'files' in l: 
        for file in l['files']:
            if 'permalink' not in file: 
                continue
            # 添付ファイルに permalink がある場合
            text+='\nFile: {}'.format(file['permalink'].replace('\\',''))
    
    # 投稿の引用の場合、To: 以下に投稿を引用形式で書く
    if show_thread_id and 'attachments' in l: 
        for attachment in l['attachments']:
            if 'fallback' not in attachment or 'ts' not in attachment: 
                continue
            # fallbackがある場合
            text+='\nTo:\n{}'.format('> '+attachment['fallback'].replace('\n','\n> ')).replace(r'"',r'\"')

    # スレッドの場合、スレッドの最初の投稿には Thread top: と、 スレッド内の投稿には In thread: と書く
    # 番号はタイムスタンプの下4桁を使う
    if show_quote_post and 'thread_ts' in l:
        if 'reply_count' in l:
            text+='\nThread top: {}'.format(l['thread_ts'].split(".")[0][-4:])
        else:
            text+='\nIn thread: {}'.format(l['thread_ts'].split(".")[0][-4:])
        
    # @ユーザーIDを置き換える
    for key,name in name_mapping.items():
        text = text.replace(f'<@{key}>',f'<@{name[1]}>') 
    return text

texts = {}

with zipfile.ZipFile(filepath, 'r') as myzip:
    infolist = myzip.infolist()
    
    # ユーザーリストのチェック
    users = json.load(myzip.open('users.json'))
    for userid in name_mapping.keys():
        assert(userid in [user['id'] for user in users])# 旧WSにユーザーが存在しない
    print("★旧WSでの名前 -> 新WSでの名前")
    for user in users:
        if user['id'] in name_mapping:
            if 'real_name' in user:
                print(user['real_name'],'->',name_mapping[user['id']][0],'OK?')
            else:
                print(user['profile']['real_name'],'->',name_mapping[user['id']][0],'OK?')
        
    # チャンネルリストのチェック
    channels = json.load(myzip.open('channels.json'))
    print("★旧WSでのチャンネル名 -> 新WSでのチャンネル名")
    for channelname in channel_mapping.keys():
        assert(channelname in [channel['name'] for channel in channels])# 旧WSにチャンネルが存在しない
        print(channelname,"->",channel_mapping[channelname])
    
    # 投稿を取得
    for info in infolist:
        filename_utf_8 = info.filename.encode('cp437').decode('utf-8')
        channel_utf_8 = filename_utf_8.split('/')[0]
        if '/' in filename_utf_8 and '.json' in filename_utf_8 and channel_utf_8 in list(channel_mapping.keys()):
            obj = json.load(myzip.open(info.filename))
            for l in obj:
                if 'text' not in l:continue # テキストがなければ無視
                if ignore_not_user == True:
                    assert(l['user'] in name_mapping) # ユーザーIDリストに存在しない投稿者がいる
                line_csv = '"{}","{}","{}","{}"\n'.format(l['ts'],channel_mapping[channel_utf_8],name_mapping[l['user']][0],get_text(l,name_mapping))
                texts[l['ts']] = line_csv

# 仕様上タイムスタンプでソート
texts_sorted = dict(sorted(texts.items(), key=lambda x:float(x[0])))

# CSVファイル出力
with open(csvpath,'w',encoding='utf_8') as f:
    for text in texts_sorted.values():
        f.write(text)

# ログ出力
print("★ログ:",len(texts_sorted),"件の投稿を出力しました")

ちゃんと準備されていれば、ちゃんと変換できるはずである。

SlackにCSVファイルで投稿をインポートする

Slackの旧ワークスペースを、その構造を保ったまま別の新ワークスペース(新WS)に移行するのではなく、特定のチャンネルの投稿だけを新WSに移行させたいとき、CSVファイルを使うのが便利だ。WSごと移行よりも、工数が少なく作業が容易である。

slack.com

に解説があるが、実際に作業してみると、ここに書かれていない仕様を理解しなければならなかった。理解した内容をメモとして残す。CSVファイルの作成方法は後日解説する。

次の2条件で移行したいとする。

  • 新WSには、移行させたいユーザーが全員参加している
  • 移行先のチャンネル種はプライベートチャンネル

Slackが例示しているCSVファイルは以下の通りである。

"1357559471","random","myles","誰か\"ジョーク\"を聞きたい?"
"1357559472","random","myles","有効な回答はこれ:
ええ、もちろん
なんでわざわざ聞くの?"

Unixtime(詳細は後述)、チャンネル名、投稿者名、投稿内容 の順でカンマ区切りで記述する。それぞれの要素は""で囲む。つまり、"がメタ文字になるので、""内で"を使うときは、例のようにバックスラッシュを付けて\"としなければならない。投稿内容では例のように改行\nを使うことができる。

ユーザーのマッピング

結論を先に言うと、どのような手段を持ってしても(注1)、ユーザーを自動でマッピングすることはできない。Slackに問い合わせたところ、自動的にマッピングされないのは既知の不具合で、修正が行われない可能性がある(私が受けた感触だと、今後修正されることはない)と連絡を受けた(2022/08/01)。CSVファイルに記述したユーザー名は、インポート時に小文字になり、スペースなどは_に置き換わってしまう。そのユーザー名を見ながら、新WSでマッチするユーザーをプルダウンから選ぶ。プルダウンに表示されるのは、displaynameなので、CSVファイルのユーザー名はdisplaynameにしておくと分かりやすいだろう。

この画像は、Slackの仕様通りにCSVファイルにusernameを書いたが、自動マッピングがされなかったので、手動で既存のユーザーをプルダウンで選ぼうとしているところである。「可能な場合にはユーザーをマッピングし、残りをマニュアルで設定する」という選択肢があるのは、何らかの開発途中の機能か、ZIPファイルでのインポートとの整合性を取るためだったか、とにかく、可能な場合というのはないので、一つ一つ手動でマッピングするしかない。

なお、displaynameに同姓同名がいる場合、プルダウンから区別することはできない。ただし、ソースには value=としてユーザーIDが書いてあるので、デベロッパーツール(開発者ツール)で識別することは可能。以下は、mem1 suzukiさんが5人いたときの例。

注1: 自動でマッピングさせたいなと思って、ユーザー名のところを、Slackで使われる username, fullname, displayname, userid, メールアドレスにしてみたが、どれも自動マッピングされなかった。Slackへの問い合わせで確定。

チャンネルのマッピング

パブリックチャンネルの場合

新WSに存在する既存のチャンネルに投稿を追加することができる。新しいチャンネルを自動で作ることも出来る。

プライベートチャンネルの場合

新WSの既存のプライベートチャンネルに投稿を追加することはできない。インポートすると、新しいプライベートチャンネルが自動作成され、そのメンバーはインポートの作業を行った管理者だけとなる。前述のユーザーのマッピングを行った場合でも、その他のユーザーはプライベートチャンネルに自動で招待されない。

インポート作業を、プライベートチャンネルに所属すべきメンバー以外の管理者が行う場合は、管理者によるインポート作業後、所属すべきメンバーのうち少なくとも一人をプライベートチャンネルに追加してもらい、その後管理者をチャンネルから外し、所属すべきメンバーを招待するという作業をしなければならない。

インポートしようとしたプライベートチャンネル名と同じチャンネルが既に存在した場合、チャンネル2 などと末尾に2が加えられた新プライベートチャンネルが自動作成される。チャンネル名は後で変更できるので、適切に運用すればよいだろう。

投稿時間 unixtime について

CSVファイルの最初の列のunixtime (Slack上ではタイムスタンプ=timestamp=TS)には小数が使える。最初の行のunixtimeで昇順ソートされていなければならない。同じCSVファイル内では同じunixtimeが使える。ただし、投稿のタイムスタンプはSlack側で勝手にずらし、異なる時間に投稿したことになる。

パブリックチャンネルに投稿をインポートした後、同じunixtimeの投稿を同じパブリックチャンネルにインポートすることはできない。した場合、「2 件のメッセージ と 0 個のファイルがインポートされました。」などと成功した感じのメッセージが出るが、実際はインポートされず、上書きもされない。既存のプライベートチャンネルへのインポートはできないので、この問題が起こるのはパブリックチャンネルだけである。

その他のtips

ユーザーを大量に手動マッピングしたくない場合や、新WSに存在しないユーザーがいる場合は、Botの投稿としてインポートすることもできる。全ての投稿をボットにするときは「ユーザーを作成せず、メッセージはボットメッセージとして残す」を選べば良い。その場合、ユーザー名はCSVに書いた通りになり、下記の画像のようにユーザー名の横に「アプリ」と表記される。

スレッド構造、ファイル、リアクションはCSVファイルでは移行できない。

対ユーザーの@投稿、@ユーザー名 は利用可能。 <@新WSのuserid>とすれば、新WSのdisplaynameに置き換わる。

CSVファイルの文字コードはUTF-8

1作業に限りロールバックできる。インポートした後で、想定した挙動をしなかったら、ロールバックすればよい。最初は、捨てWSを作って、挙動を確認することをお勧めする。

CSVファイルによるインポートでフリープラン90日問題の影響を回避できるわけではない。本件の新WSはエンタープライズ(有料版)のSlackで運用されており、フリープラン90日問題の影響は受けない。ただし、CSVファイルでのインポートを使うと投稿日時を自在にずらすことができる。インポートによる投稿日時が90日ルールでどう判定されるかは分からない。過去の投稿を新しいタイムスタンプで再インポートし続けるという手もあるが、実用上うまくいくとは思えない。

インポートしたチャンネルでは、投稿の間にある 日付等 の右のvをクリックすると出てくる「今日、機能、先週、先月」には正しく反応するが、「最初」は最初ではなくインポート時点に飛ばされる。「カレンダーを使った移動」は、実際の投稿日である過去の日付、つまりインポートした日より前を選ぶことができない。→Slackに問い合わせた結果、2022/07/22時点では、

いただいた内容をもとに確認したところ、ご指摘のとおり「特定の日付に移動」についてはインポート以前の日付には機能しないことがわかりました。 この仕様が理想的な動作でないことはチーム一同認識しており、今後の動作改善を検討しています。

とのこと。回避策として検索機能を使って探すことを勧められた。カレンダー機能を頻繁に使っている場合は留意。