generated at
複数のリンクをまとめて置換するUserScript
本文を直接編集することなく、複数のリンクを一度にリンク置換 (scrapbox)するscript

interface
このUserScriptはコアプログラムのみを提供する
実際に使用するには、各自でPopupMenuやkeyboard shortcutを設定すること
mod.ts
コアプログラム
指定されたリンクを置換する
converter.ts
一括置換用UI
置換したいリンクを含んだテキストを入力すると、置換用editorがmodal windowで現れる
テキストからリンクを抽出してeditorに書き並べる
置換後のリンク名が見つからない(置換前と置換後のリンクリストの行数が一致しない)場合、それらは置換しないとして処理する
置換後のリンクリストの行数のほうが多いときは、過剰分を単に無視する
サンプルコード
一括置換UIで置換先リンクを指定して置換する
置換中は .status-bar に進捗を表示する
PopupMenuとして実装した例


やりたいこと
置換UI
置換前後の差分を表示したい
mobile横画面の場合は、左か右横に置く
置換進捗表示UI
右上に表示したい
枠の中に置換中のリンクを羅列する
置換中はスピンマークを出し、置換が完了したものからチェックマークに変える
全部置換したら消える
置換前のないリンクは、新しいリンクとみなす
う~ん、書き込み先を決められないな……

2024-11-13
15:34:28
scrapbox-userscript-stdの破壊的変更に対応
async-libを外してdeno_std/asyncを入れる
replace の型定義を変更
callback をなくし、 AsyncIterable で返す
16:03:52 当初きれいにやろうと思ったが、pooledMapのflatMap ver.がなく、難航した
仕方なく、内部ではcallbackを残し、返すときにasync iteratorにすることにする
2023-11-03
07:15:37 行ごとにリンクを検知する
2023-10-21
15:26:41 リンク抽出用正規表現をScrapboxの内部リンクにmatchする正規表現にした
今までは一部のリンクをリンクと見なせていなかった
2023-10-04
07:29:00 getLink を使わずに、 getLinks のみ使う
2023-09-26
10:21:50 editorをJSXと<dialog>で組んだmodal windowを参考に書き換えた
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
14:20:33 置換進捗UIは、useStatusBar()で簡単に実装することにする
13:59:03 テスト開始
14:09:56 よさげ。一括変更できた
13:42:00 なんだかんだで、入力側のUIはできてしまった
このまま進めよう
13:09:59 コア機能の replace はできた
ステータス表示機能も分離した
UIになかなか手が付かない

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(); }); // lib.dom.d.tsに[Symbol.asyncIterator]がまだ実装されていない 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; };
mod.ts
export const getLinks = (text: string): string[] => text.split("\n") .flatMap( (line) => [...line.matchAll(/\[((?:[^\[!"#%&'()\*\+,\-\.\/\{\|\}<>_~] |.[^ ]*)[^\[\]]*)\]/g)] ) .map(([, link]) => link); const toError = (e: ErrorLike): Error => { const error = new Error(); error.name = e.name; error.message = e.message; return error; };

メインプログラム
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/複数のリンクをまとめて置換するUserScript/main.ts
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); const result = await waitForConvertOrder([...new Set(links)]); if (!result.convert || result.links.length === 0) return; const bars = new Map(result.links.map((link) => [link.before, useStatusBar()])); 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を使う
リンクが一つだけの場合は、Projectを横断してリンクを置換するUserScriptにfallbackする
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/複数のリンクをまとめて置換するUserScript/example.ts
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 => { const p = [scrapbox.Project.name, ...projects]; 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
仕様
✅編集部分は<textarea>を用いる
編集前と編集後のリンクを並べる
画面幅が広いときは横並び
狭いときは縦並び
xボタンもしくは背景クリックでキャンセルする
confirmしたら、modalに変換状況を表示する
✅変換終了後、背景クリックかOKボタンで画面をdialogを閉じる
実装
V2
Preactは引き続き使用しない
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 === newLines[i] || before === "" || !newLines[i]) return []; return [{ before, after: newLines[i] ?? before }]; } ), }); root.remove(); }); dialog.querySelector(".container")!.addEventListener("click", (e) => { e.stopPropagation(); }); }); document.body.append(root); dialog.showModal(); return promise; };
V1

Preactで実装しようとしたときの残骸
App.tsx
interface Controller { /** * * @return 結果 */ start: (projects: string[], links: string[]) => Promise< "success" | "cancel" | "failed" >; close: () => void; } const App = ({ getController }: AppProps) => { const [after, setAfter] = useState(""); const [before, setBefore] = useState(""); const [projects, setProjects] = useState<string[]>([]); const [onEnd, setOnEnd] = useState<(result: Result) => void>(() => {}); const linksDiff = useMemo(() => { const lines = after.split("\n"); // 空文字の場合は、変更なしとみなす return links.map((link, i) => ({ before: link, after: lines[i]?.trim?.() || link })); }, [links, after]); const replace = useCallback(async () => { setReplacing(true); await replaceLinks(linksDiff, projects); setReplacing(false); setOpen(false); onEnd("success"); }, [linksDiff, projects, onEnd]); 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), }), [getController]); 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 /> の中に貼り付ける

#2023-10-21 15:26:16
#2023-10-04 07:28:56
#2023-09-26 09:19:21
#2023-02-24 05:15:05
#2023-02-09 06:07:38
#2022-11-21 19:27:42
#2022-11-13 13:09:52
#2022-09-12 04:33:50
#2022-07-31 15:11:26
#2022-07-29 14:07:47