複数のリンクをまとめて置換するUserScript
interface
このUserScriptはコアプログラムのみを提供する
実際に使用するには、各自でPopupMenuやkeyboard shortcutを設定すること
mod.ts
コアプログラム
指定されたリンクを置換する
converter.ts
一括置換用UI
テキストからリンクを抽出してeditorに書き並べる
置換後のリンク名が見つからない(置換前と置換後のリンクリストの行数が一致しない)場合、それらは置換しないとして処理する
置換後のリンクリストの行数のほうが多いときは、過剰分を単に無視する
サンプルコード
一括置換UIで置換先リンクを指定して置換する
置換中は.status-barに進捗を表示する
PopupMenuとして実装した例
やりたいこと
置換UI
置換前後の差分を表示したい
https://gyazo.com/8d1ed4ec1f8c465d729883cc411c7977
mobile横画面の場合は、左か右横に置く
置換進捗表示UI
右上に表示したい
枠の中に置換中のリンクを羅列する
置換中はスピンマークを出し、置換が完了したものからチェックマークに変える
全部置換したら消える
置換前のないリンクは、新しいリンクとみなす
う~ん、書き込み先を決められないな……
2024-11-13
15:34:28
replaceの型定義を変更
callbackをなくし、AsyncIterableで返す
16:03:52 当初きれいにやろうと思ったが、pooledMapのflatMap ver.がなく、難航した 仕方なく、内部ではcallbackを残し、返すときにasync iteratorにすることにする
2023-11-03
07:15:37 行ごとにリンクを検知する
2023-10-21
今までは一部のリンクをリンクと見なせていなかった
2023-10-04
07:29:00 getLinkを使わずに、getLinksのみ使う
2023-09-26
2023-02-24
05:15:32 example.tsでpとprojectsを取り違えていた
2023-02-09
06:09:15
projectsの重複除去処理を入れた
projectsとlinksをIterableで受け取るようにした
example.tsでpopupmenuを設定するのをやめ、projectsを自由に設定できる函数setup()をexportするようにした
2022-11-13
13:59:03 テスト開始
14:09:56 よさげ。一括変更できた
13:42:00 なんだかんだで、入力側のUIはできてしまった
このまま進めよう
13:09:59 コア機能のreplaceはできた
ステータス表示機能も分離した
UIになかなか手が付かない
code:mod.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
import {
disconnect,
connect,
patch,
replaceLinks,
type ScrapboxSocket,
} from "../scrapbox-userscript-std/mod.ts";
import type { ErrorLike } from "../scrapbox-jp%2Ftypes/rest.ts";
import { pooledMap } from "jsr:@std/async@1/pool";
import { isErr, unwrapErr, unwrapOk } from "npm:option-t@49/plain_result";
export interface Link {
before: string;
after: string;
}
export interface ReplaceState {
link: Link;
projectCount: number;
replaced: number;
done: boolean;
};
export async function* replace(
links: Link[],
projects: Iterable<string>,
): AsyncGenerator<ReplaceState, void, unknown> {
if (links.length === 0) return;
if (links.every(({ before, after }) => before === after)) return;
// throw an exception when this result is not ok.
const socket: ScrapboxSocket = unwrapOk(await connect());
try {
const { readable, writable } = new TransformStream<ReplaceState, ReplaceState>(undefined);
const writer = writable.getWriter();
const iter = pooledMap(
5,
links,
async (link) => {
let count = 0;
let replaced = 0;
if (link.before === link.after) {
await writer.ready;
await writer.write({ link, projectCount: 0, replaced: 0, done: true });
}
const iter = pooledMap(
2,
new Set(projects),
async (project) => {
const result = await replaceAlink(link, project, socket);
if (isErr(result)) throw toError(unwrapErr(result));
count++;
replaced += unwrapOk(result);
await writer.ready;
await writer.write({ link, projectCount: count, replaced, done: false });
},
);
await Array.fromAsync(iter);
await writer.ready;
await writer.write({ link, projectCount: count, replaced, done: true });
},
);
const done = Array.fromAsync(iter).then(async () => {
await writer.ready;
await writer.close();
});
const reader = readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) return;
yield value;
}
await done;
} finally {
await disconnect(socket);
}
};
/** 一つのリンクを一つのprojectで置換する
*
* @return replaceLinksと同じ
*/
const replaceAlink = async (link: Link, project: string, socket: ScrapboxSocket) => {
const result = await Promise.all([ // 本当はhasBackLinksOrIcons === trueのときのみ置換したい
replaceLinks(project, link.before, link.after),
patch(project, link.before, (lines, { persistent }) => {
if (!persistent) return;
return [
link.after,
...lines.map((line) => line.text).slice(1),
];
}, { socket }),
]);
return result;
};
code:mod.ts
export const getLinks = (text: string): string[] =>
text.split("\n")
.flatMap(
)
.map((link) => link);
const toError = (e: ErrorLike): Error => {
const error = new Error();
error.name = e.name;
error.message = e.message;
return error;
};
メインプログラム
code:main.ts
import { useStatusBar } from "../scrapbox-userscript-std/mod.ts";
import { replace, getLinks } from "./mod.ts";
import { waitForConvertOrder } from "./converter.ts";
export { getLinks };
export const handleReplace = async (text: string, projects: string[]): Promise<void> => {
const links = getLinks(text);
if (!result.convert || result.links.length === 0) return;
for await (const { link, projectCount, replaced, done } of replace(result.links, projects)) {
const bar = bars.get(link.before);
if (!bar) return;
if (done) {
bar.render(
{ type: "check-circle"},
{ type: "text", text: "${link.after}", ${replaced}l. ${projectCount}p. },
);
setTimeout(() => bar.dispose(), 1000);
return;
}
bar.render(
{ type: "spinner"},
{ type: "text", text: "${link.after}", ${replaced}l. ${projectCount}p. },
);
}
};
サンプル:PopupMenuを使う
code:example.ts
import { handleReplace, getLinks } from "./main.ts";
import { replace } from "../Projectを横断してリンクを置換するUserScript/mod.ts";
import type { Scrapbox } from "../scrapbox-jp%2Ftypes/userscript.ts";
declare const scrapbox: Scrapbox;
export const setup = (projects: Iterable<string>): void => {
scrapbox.PopupMenu.addButton({
title: (text) => {
const linkCount = getLinks(text).length;
return linkCount > 1 ? "update links" : linkCount === 1 ? "update a link" : "";
},
onClick: (text): undefined => {
const linkCount = getLinks(text).length;
if (linkCount > 1) {
handleReplace(text, p);
} else if (linkCount === 1) {
replace(text, p);
}
return undefined;
},
});
};
置換editor
仕様
編集前と編集後のリンクを並べる
画面幅が広いときは横並び
狭いときは縦並び
xボタンもしくは背景クリックでキャンセルする
confirmしたら、modalに変換状況を表示する
✅変換終了後、背景クリックかOKボタンで画面をdialogを閉じる
実装
V2
Preactは引き続き使用しない
code:converter.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
export interface Link {
before: string;
after: string;
}
export type ConvertOrder = {
convert: false;
} | {
convert: true;
links: Link[];
};
export const waitForConvertOrder = (links: string[]): Promise<ConvertOrder> => {
const root = document.createElement("div");
const shadowRoot = root.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = dialog::backdrop{background-color:#000c}dialog{flex-direction:column;align-items:center;row-gap:10px;padding:10px;background:unset;margin-top:unset;margin-bottom:unset;border:unset;height:unset}dialog[open]{display:flex}dialog>*{color:var(--page-text-color, #4a4a4a);background-color:var(--dropdown-menu-bg, #fff);border:1px solid rgba(0,0,0,.2);border-radius:6px}@media (min-width: 768px){dialog{padding:30px 0}}.container{display:flex;padding:5px;gap:0.2em;flex-direction:column;width:100%;}.button-container{flex-direction:unset;}.button-container>*{flex:1;};
shadowRoot.append(style);
const dialog = document.createElement("dialog");
dialog.insertAdjacentHTML("beforeend", `
<div class="container">
Replace Links:
<textarea class="editor"></textarea>
<div class="container button-container">
<button class="cancel">cancel</button>
<button class="replace">replace</button>
</div>
</div>
`);
shadowRoot.append(dialog);
const editor = dialog.querySelector(".editor") as HTMLTextAreaElement;
editor.rows = links.length;
editor.value = links.join("\n");
const adjustWidth = () => {
dialog.style.minWidth = `${
Math.max(...editor.value.split("\n").map((line) => ...line.length)) + 6 }em`;
};
adjustWidth();
editor.addEventListener("input", adjustWidth);
const cancel = dialog.querySelector(".cancel") as HTMLButtonElement;
const confirm = dialog.querySelector(".replace") as HTMLButtonElement;
const promise = new Promise<ConvertOrder>((resolve) => {
const onClose = () => {
resolve({ convert: false });
root.remove();
};
dialog.addEventListener("close", onClose);
dialog.addEventListener("click", onClose);
cancel.addEventListener("click", onClose);
confirm.addEventListener("click", () => {
const newLines = editor.value.split("\n");
resolve({
convert: true,
links: links.flatMap(
(before, i) => {
// 空文字の場合と、変化がない場合は飛ばす
if (before === newLinesi || before === "" || !newLinesi) return []; return before, after: newLinesi ?? before }; }
),
});
root.remove();
});
dialog.querySelector(".container")!.addEventListener("click", (e) => {
e.stopPropagation();
});
});
document.body.append(root);
dialog.showModal();
return promise;
};
V1
Preactで実装しようとしたときの残骸
code:App.tsx
interface Controller {
/**
*
* @return 結果
*/
start: (projects: string[], links: string[]) => Promise<
"success" | "cancel" | "failed"
;
close: () => void;
}
const App = ({ getController }: AppProps) => {
const linksDiff = useMemo(() => {
const lines = after.split("\n");
// 空文字の場合は、変更なしとみなす
return links.map((link, i) => ({ before: link, after: linesi?.trim?.() || link })); const replace = useCallback(async () => {
setReplacing(true);
await replaceLinks(linksDiff, projects);
setReplacing(false);
setOpen(false);
onEnd("success");
const handleInput = useCallback((e) => setAfter(e.currentTarget.value), []);
const startReplace = useCallback((projects: string[], links: string[]) => {
setProject(projects);
setBefore(links.join("\n"));
setOpen(true);
return new Promise<Result>((resolve) => setOnEnd(resolve));
}, []);
useEffect(() => getController({
start: startReplace,
close: () => setOpen(false),
return (
<>
<style>
{''}
</style>
<div id="background" className={modal${closed ? " closed" : ""}} role="dialog" onClick={handleClose}>
<div className="container">
<div className="preview">
Replace {linksDiff.length} links
<ul>
{linksDiff.map((link) => (
<li className={
link.before !== link.after ? "emphasis" : ""
}>{
link.after
}</li>
))}
</ul>
</div>
<textarea value={after} onInput={handleInput} />
<div className="footer">
<button className={replacing ? "inactive" : "active"} onClick={close}>Cancel</button>
<button className={replacing ? "inactive" : "active"} onClick={replace}>Replace</button>
</div>
</div>
</div>
</>
);
};
CSS
minifyしたものを<App />の中に貼り付ける
code:app.css