オンプレPukiwikiからesa.ioに移行する\( ⁰⊖⁰)/

# 概要
弊社では、私が入社する前からオンプレのPukiWikiを運用していましたが、この度esa.ioに移行しました\( ⁰⊖⁰)/
私がesaにホレこんだのが一番の理由ですが、オンプレの管理メンドクセ、とかWiki記法よりMarkdown使いたいよね、とか色々不満が溜まっていた事もあります。
本記事は、PukiWikiに溜め込んだ記事を、ドサッとesa.ioに移行した作業ログみたいなものです。
恥ずかしながら、移行プログラムも公開しますので、同じような事やりたい人は参考にしてみてください。

# esa.ioとは
https://esa.io/
簡単に言うと、Wikiっぽい情報共有ツールです。
情報共有のハードルをすごく低くしてあるのと、記事の編集や管理を超ラクにしてくれる、様々な「かゆい所に手が届く」機能の数々が気に入っています。
無料体験で実際使ってみると、「おぉっ!」の連続でした。

- スクショをCtrl + vですぐ貼れる!
- Ctrl + eで編集モードに入ると、見てた所がすぐ開く!
- Mermaid.jsでフローチャートも書けるやないか!
- うっかり増やしまくったRevisionはSquashできるんか～い!

# 移行していくよ\( ⁰⊖⁰)/

## こんな方針

- PukiWikiサーバには、1記事ごとに1ファイルのtxtファイルとして保存されているので、それを1つずつesaのAPIで投稿していく
- 元記事はwiki記法なので、Markdownに変換して投稿する
- 全部投稿し終わったら、内部リンク(記事から記事へのリンク)を貼るために1回ずつ更新する(esa内のリンクは、記事番号が決まらないと貼れないので、一度投稿して記事番号を確定する必要がある)
- 画像は移行しない、けど移行元Wikiの画像にリンクさせるので、esa内で表示はできる

## あきらめた事

- あまり一般的でない(使用頻度が低い)Wiki記法はMarkdownに変換しない
- 添付ファイルは移行しない(そもそもあまり添付されていないので助かった)
- その他細かい所は気にしない

## 移行の手順

記事のtxtファイルは、PukiWikiサーバの「wiki」フォルダに溜まっています。
これを所定の場所にコピー。そんで↓の移行プログラムを実行すればOK。
移行プログラムはjavaで作りました。

esaのweb APIを使うので、事前にアクセストークンを取得します。
チーム設定の「Applications」から、「Personal access tokens」の部分で取得できます。

## 移行プログラム
使い捨てのプログラムなので、グァーっと勢いで作りました。
読みやすくする工夫とか一切していないので、ちょっといじって使いたい時とかは、やりにくいと思います。。。「ここ意味わかんね」とかあったら、コメントで聞いてください。
プログラム中で★がついている部分を、ご自分の環境に合わせて変更してから実行してください。
esaのAPIは、15分間に75回までしか呼べない仕様ですが、その制限を超えた場合には、また呼べるようになるまでじっと待つようにしています。

### メインプログラム

```java
public class WikiMigrationToEsa
{
	private static Client apiClient = ClientBuilder.newClient();
	
	private static MutableList<Pair<Integer, Twin<String>>> documentList = Lists.mutable.empty();
	
	//★wikiの記事ファイルが置いてあるフォルダを指定
	private static final String wikiFilePath = "C:\\Users\\hoge\\wiki";
	//★esa.ioのアクセストークン
	private static final String authorizationKey = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
	//★Team名
	private static final String teamName = "hogehoge";
	
	public static void main(String[] args)
	{
		//wikiの記事ファイルを1件ずつesa.ioに投稿
		try (final Stream<Path> pathStream = Files.walk(Paths.get(wikiFilePath))) 
		{
			pathStream
				.map(path -> path.toFile())
				.filter(file -> !file.isDirectory())
				.filter(file -> file.getName().toLowerCase().endsWith(".txt"))
				.forEach(file -> postWikiFile(file.toPath()));
		} catch (final IOException e) {
			e.printStackTrace();
			return;
		}
		//投稿済の記事について、wiki内リンクをesa.io書式に更新する
		patchInnerLink();
	}
	
	public static void postWikiFile(Path wikiItemFile)
	{
		EsaPostItemDto content = new EsaPostItemDto();
		content.post = new EsaPostBodyDto();
		StringBuilder bodyText = new StringBuilder();
		//タイトル
		String wikiFileName = StringUtils.substringBefore(wikiItemFile.toFile().getName(), ".");
		byte[] bytes;
		try
		{
			bytes = Hex.decodeHex(wikiFileName);
		} catch (DecoderException e)
		{
			e.printStackTrace();
			return;
		}
		String itemName = "";
		try
		{
			itemName = new String(bytes, "EUC-JP");
		} catch (UnsupportedEncodingException e)
		{
			e.printStackTrace();
			return;
		}
		//タイトルの半角スラッシュがカテゴリとして認識されてしまう問題に対処
		itemName = RegExUtils.replaceAll(itemName, "/", "&#47;");
		
		try (FileInputStream fileInputStream = new FileInputStream(wikiItemFile.toFile());
				InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, "EUC-JP");
				BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
			)
		{
			String lineTmp;
			int lineNo = 0;
			boolean isCodeBlock = false;
			boolean isTable = false;
			while ((lineTmp = bufferedReader.readLine()) != null) {
				lineNo++;
				byte[] lineBytes = lineTmp.getBytes();
				String line = new String(lineBytes, "UTF-8");
				if(lineNo == 1)
				{
					if(StringUtils.startsWithIgnoreCase(line, "#freeze"))
					{
						return;
					}
				}
				if(StringUtils.startsWithIgnoreCase(line, "#ref") || StringUtils.startsWithIgnoreCase(line, "&ref"))
				{
					String fileName = StringUtils.substringBetween(line, "(", ")");
					fileName = RegExUtils.replaceAll(fileName, "(^[^\\.]+\\.[^,]+),.+", "$1");
					line = "![" + fileName + "](http://hoge/fuga/index.php?plugin=attach&refer=" + URLEncoder.encode(itemName, "EUC-JP") + "&openfile=" + URLEncoder.encode(fileName, "EUC-JP") + ")";
				}
				if(!isCodeBlock && StringUtils.startsWith(line, " "))
				{
					isCodeBlock = true;
					bodyText.append("```");
					bodyText.append(System.getProperty("line.separator"));
				}
				if(isCodeBlock)
				{
					if(StringUtils.startsWith(line, " "))
					{
						bodyText.append(line);
						bodyText.append(System.getProperty("line.separator"));
						continue;
					}
					else
					{
						bodyText.append("```");
						bodyText.append(System.getProperty("line.separator"));
						isCodeBlock = false;
					}
				}
				if(!isTable && StringUtils.startsWith(line, "|"))
				{
					isTable = true;
					int columnCount = StringUtils.countMatches(line, "|");
					bodyText.append(System.getProperty("line.separator"));
					if(StringUtils.endsWith(line, "h"))
					{
						bodyText.append(StringUtils.substringBeforeLast(line, "h"));
						bodyText.append(System.getProperty("line.separator"));
						bodyText.append(StringUtils.repeat("|", "-----", columnCount));
						bodyText.append(System.getProperty("line.separator"));
						continue;
					}
					else
					{
						bodyText.append(StringUtils.repeat("|", "header", columnCount));
						bodyText.append(System.getProperty("line.separator"));
						bodyText.append(StringUtils.repeat("|", "-----", columnCount));
						bodyText.append(System.getProperty("line.separator"));
						bodyText.append(line);
						bodyText.append(System.getProperty("line.separator"));
						continue;
					}
				}
				if(isTable)
				{
					if(StringUtils.startsWith(line, "|"))
					{
						bodyText.append(line);
						bodyText.append(System.getProperty("line.separator"));
						continue;
					}
					else
					{
						bodyText.append(System.getProperty("line.separator"));
						isTable = false;
					}
				}
				line = RegExUtils.replaceAll(line, "\\[#[0-9a-z]+\\]$", "");
				line = RegExUtils.replaceAll(line, "^\\*\\*\\*", "### ");
				line = RegExUtils.replaceAll(line, "^\\*\\*", "## ");
				line = RegExUtils.replaceAll(line, "^\\*", "# ");
				line = RegExUtils.replaceAll(line, "^---", "        - ");
				line = RegExUtils.replaceAll(line, "^--", "    - ");
				line = RegExUtils.replaceAll(line, "^-", "- ");
				line = RegExUtils.replaceAll(line, "^\\+\\+\\+", "        1\\. ");
				line = RegExUtils.replaceAll(line, "^\\+\\+", "    1\\. ");
				line = RegExUtils.replaceAll(line, "^\\+", "1\\. ");
				line = RegExUtils.replaceAll(line, "^\\+", "1\\. ");
				line = RegExUtils.replaceAll(line, "''", "\\*\\*");
				line = RegExUtils.replaceAll(line, "%%", "~~");
				line = RegExUtils.replaceAll(line, "\\[\\[([^\\]^:^>]+)[:|>]([^:^/^\\]]+://[^\\]]+)\\]\\]", "\\[$1\\]\\($2\\)");
				bodyText.append(line);
				bodyText.append(System.getProperty("line.separator"));
			}
		}
		catch (IOException e)
		{
			e.printStackTrace();
			return;
		}
		
		content.post.body_md = bodyText.toString();
		content.post.name = itemName;
		content.post.wip = false;
		content.post.message = "wikiからの移行";
		content.post.user = "esa_bot";
		
		Response apiResponse = apiClient.target("https://api.esa.io/v1/teams/" + teamName + "/posts")
			.request()
			.header("Authorization", "Bearer " + authorizationKey)
			.header("Content-Type", "application/json")
			.post(Entity.entity(content, MediaType.APPLICATION_JSON));
		System.out.println(apiResponse.getStatus());
		JSONObject postResponseBody = new JSONObject(apiResponse.readEntity(String.class));
		System.out.println("number : " + postResponseBody.getLong("number"));
		documentList.add(
				Tuples.pair(
						postResponseBody.getInt("number")
						,Tuples.twin(
								itemName
								, bodyText.toString()
								)
						)
					);
		waitAPILimit(apiResponse);
	}
	
	
	private static void patchInnerLink()
	{
		Pattern linkPattern = Pattern.compile("(\\[\\[[^\\]^>]+>[^\\]^:^/]+\\]\\]|\\[\\[[^\\]^>]+\\]\\])");
		documentList.each(post -> {
			Twin<String> postItem = post.getTwo();
			Matcher postMatcher = linkPattern.matcher(postItem.getTwo());
			StringBuffer sb = new StringBuffer();
			while(postMatcher.find())
			{
				String linkString = postMatcher.group();
				//linkStringはエイリアスがある場合とない場合があるため、エイリアスの場合は>の後を取り出す
				if(StringUtils.contains(linkString, ">"))
				{
					linkString = StringUtils.substringAfter(linkString, ">");
				}
				final String linkItemName = RegExUtils.removeAll(linkString, "\\[|\\]");
				Pair<Integer, Twin<String>> linkedItem = documentList.select(post2 -> StringUtils.equals(linkItemName, post2.getTwo().getOne())).getFirst();
				if(linkedItem != null)
				{
					postMatcher.appendReplacement(sb, "[#" + linkedItem.getOne() + ": " + linkItemName + "](/posts/" + linkedItem.getOne() + ")");
				}
			}
			postMatcher.appendTail(sb);
			
			EsaPostItemDto content = new EsaPostItemDto();
			content.post = new EsaPostBodyDto();
			content.post.body_md = sb.toString();
			content.post.name = postItem.getOne();
			content.post.wip = false;
			content.post.message = "内部リンク更新";
			content.post.updated_by = "esa_bot";
			Response patchResponse = apiClient
					.property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true)
					.target("https://api.esa.io/v1/teams/" + teamName + "/posts/" + post.getOne())
					.request()
					.header("Authorization", "Bearer " + authorizationKey)
					.header("Content-Type", "application/json")
					.method("PATCH", Entity.entity(content, MediaType.APPLICATION_JSON))
					;
			System.out.println(patchResponse.getStatus());
			waitAPILimit(patchResponse);
		});
	}
	
	private static void waitAPILimit(Response apiResponse)
	{
		String remaining = apiResponse.getHeaderString("X-RateLimit-Remaining");
		System.out.println("X-RateLimit-Remaining : " + remaining);
		if(StringUtils.equals(remaining, "0"))
		{
			System.out.println("X-Rate-Limit-Reset : " + apiResponse.getHeaderString("X-RateLimit-Reset"));
			OffsetDateTime reset = OffsetDateTime.ofInstant(Instant.ofEpochSecond(Long.valueOf(apiResponse.getHeaderString("X-RateLimit-Reset"))), ZoneId.systemDefault());
			System.out.println("X-Rate-Limit-Reset : " + reset.format(DateTimeFormatter.ISO_DATE_TIME));
			//時計が合ってない可能性を考慮して1分追加
			reset = reset.plusMinutes(1);
			while(reset.isAfter(OffsetDateTime.now(ZoneId.systemDefault())))
			{
				try
				{
					TimeUnit.MINUTES.sleep(1);
				} catch (InterruptedException e)
				{
					e.printStackTrace();
					System.exit(0);
				}
			}
		}
	}
}
```

### esa APIのJSONを表現するためのDTO

```java:EsaPostBodyDto.java
public class EsaPostBodyDto
{
	public String name;
	public String body_md;
	public List<String> tags;
	public String category;
	public boolean wip;
	public String message;
	public String user;
	public String updated_by;
}
```

```java:EsaPostItemDto.java
public class EsaPostItemDto
{
	public EsaPostBodyDto post;
}
```

### 使ってるライブラリなど

```pom.xml
	<dependencies>
		<dependency>
			<groupId>commons-net</groupId>
			<artifactId>commons-net</artifactId>
			<version>3.3</version>
		</dependency>
		<dependency>
			<groupId>commons-logging</groupId>
			<artifactId>commons-logging</artifactId>
			<version>1.2</version>
		</dependency>
		<!-- servlet-api -->
		<!-- https://mvnrepository.com/artifact/javax.servlet/servlet-api -->
		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>servlet-api</artifactId>
			<version>2.5</version>
			<scope>provided</scope>
		</dependency>
		<!-- Apache Commons Lang -->
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-lang3</artifactId>
			<version>3.8</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/commons-codec/commons-codec -->
		<dependency>
			<groupId>commons-codec</groupId>
			<artifactId>commons-codec</artifactId>
			<version>1.11</version>
		</dependency>
		<!-- jersey -->
		<!-- https://mvnrepository.com/artifact/org.glassfish.jersey.media/jersey-media-json-jackson -->
		<dependency>
			<groupId>org.glassfish.jersey.media</groupId>
			<artifactId>jersey-media-json-jackson</artifactId>
			<version>2.25</version>
		</dependency>
		<dependency>
			<groupId>org.glassfish.jersey.containers</groupId>
			<artifactId>jersey-container-servlet</artifactId>
			<version>2.25</version>
		</dependency>
		<!-- eclipse collections -->
		<dependency>
			<groupId>org.eclipse.collections</groupId>
			<artifactId>eclipse-collections-api</artifactId>
			<version>8.0.0</version>
		</dependency>
		<dependency>
			<groupId>org.eclipse.collections</groupId>
			<artifactId>eclipse-collections</artifactId>
			<version>8.0.0</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/org.json/json -->
		<dependency>
			<groupId>org.json</groupId>
			<artifactId>json</artifactId>
			<version>20180813</version>
		</dependency>
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.11</version>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.hamcrest</groupId>
			<artifactId>hamcrest-library</artifactId>
			<version>1.3</version>
			<scope>test</scope>
		</dependency>
	</dependencies>
```

# 参考
[esaのAPIリファレンス](https://docs.esa.io/posts/102)