Skip to content

Commit 42e499b

Browse files
authored
feat: add InCatEdit plugin (#13)
1 parent 9d89e99 commit 42e499b

File tree

6 files changed

+334
-0
lines changed

6 files changed

+334
-0
lines changed

packages/in-cat-edit/package.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "inpageedit-plugin-in-cat-edit",
3+
"description": "Add IPE edit button for category items and their main/talk page.",
4+
"version": "1.0.0",
5+
"author": "PexEric",
6+
"license": "MIT",
7+
"private": true,
8+
"type": "module",
9+
"module": "./dist/index.mjs",
10+
"files": [
11+
"dist"
12+
],
13+
"exports": {
14+
".": {
15+
"import": "./dist/index.mjs"
16+
}
17+
},
18+
"scripts": {
19+
"dev": "vite build --watch",
20+
"build": "vite build"
21+
},
22+
"dependencies": {
23+
"jsx-dom": "^8.1.6"
24+
},
25+
"$ipe": {
26+
"name": "InCatEdit",
27+
"categories": [],
28+
"loader": {
29+
"kind": "module",
30+
"entry": "dist/index.mjs",
31+
"styles": [
32+
"dist/style.css"
33+
],
34+
"main_export": "default"
35+
},
36+
"dev_loader": {
37+
"entry": "src/index.tsx",
38+
"styles": [
39+
"src/style.scss"
40+
]
41+
}
42+
}
43+
}

packages/in-cat-edit/src/index.tsx

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import './style.scss'
2+
import { defineIPEPlugin } from '~~/defineIPEPlugin.js'
3+
import type { IWikiTitle } from '@inpageedit/core'
4+
5+
// JSX Components
6+
const EditButton = ({
7+
isRedLink,
8+
onClick,
9+
}: {
10+
isRedLink: boolean
11+
onClick: (e: MouseEvent) => void
12+
}) =>
13+
(
14+
<a
15+
href="#ipe://quick-edit/"
16+
class={`ipe-quick-edit ${isRedLink ? 'ipe-quick-edit--create-only' : ''}`}
17+
style="user-select: none; margin-left: 0.2em;"
18+
onClick={onClick}
19+
>
20+
<svg
21+
xmlns="http://www.w3.org/2000/svg"
22+
width="24"
23+
height="24"
24+
viewBox="0 0 24 24"
25+
fill="none"
26+
stroke="currentColor"
27+
stroke-width="2"
28+
stroke-linecap="round"
29+
stroke-linejoin="round"
30+
class="icon icon-tabler icons-tabler-outline icon-tabler-pencil-bolt ipe-icon"
31+
>
32+
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
33+
<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4" />
34+
<path d="M13.5 6.5l4 4" />
35+
<path d="M19 16l-2 3h4l-2 3" />
36+
</svg>
37+
</a>
38+
) as HTMLAnchorElement
39+
40+
const CounterpartLinks = ({
41+
counterpartTitle,
42+
isMissing,
43+
onCounterpartEditClick,
44+
}: {
45+
counterpartTitle: IWikiTitle
46+
isMissing: boolean
47+
onCounterpartEditClick: (e: MouseEvent) => void
48+
}) => {
49+
const isCounterpartSubject = counterpartTitle.equals(counterpartTitle.getSubjectPage())
50+
const counterpartText = isCounterpartSubject ? 'Main' : 'Talk'
51+
const counterpartPrefixed = counterpartTitle.getPrefixedText()
52+
53+
let href = counterpartTitle.getURL().toString()
54+
if (isMissing) {
55+
const url = new URL(href, window.location.origin)
56+
url.searchParams.set('action', 'edit')
57+
url.searchParams.set('redlink', '1')
58+
href = url.pathname + url.search
59+
}
60+
61+
return (
62+
<span class="ipe-in-cat-edit-counterpart">
63+
{' ('}
64+
<a href={href} title={counterpartPrefixed} class={isMissing ? 'new' : ''}>
65+
{counterpartText}
66+
</a>
67+
<EditButton isRedLink={isMissing} onClick={onCounterpartEditClick} />
68+
{')'}
69+
</span>
70+
) as HTMLSpanElement
71+
}
72+
73+
type AnchorModel = {
74+
$el: HTMLAnchorElement
75+
titlePrefixed: string
76+
counterpart?: {
77+
title: IWikiTitle
78+
prefixed: string
79+
}
80+
}
81+
82+
export default defineIPEPlugin({
83+
name: 'in-cat-edit',
84+
inject: ['quickEdit', 'inArticleLinks', 'api', 'wiki'],
85+
apply: (ctx) => {
86+
// Only run in Category namespace
87+
const ns = ctx.wiki.mwConfig.get('wgNamespaceNumber')
88+
if (ns !== 14) return
89+
90+
mw.hook('wikipage.content').add(async ($content: any) => {
91+
const content = $content[0]
92+
if (!content) return
93+
94+
const allContainers = Array.from(
95+
content.querySelectorAll(
96+
'.mw-category, #mw-subcategories, #mw-pages, #mw-category-media'
97+
)
98+
) as HTMLElement[]
99+
100+
// Filter out containers that are inside other containers to avoid double processing
101+
const uniqueContainers = allContainers.filter((container, _, self) => {
102+
return !self.some((other) => other !== container && other.contains(container))
103+
})
104+
105+
if (!uniqueContainers.length) return
106+
107+
const anchors: any[] = []
108+
uniqueContainers.forEach((container) => {
109+
anchors.push(...ctx.inArticleLinks.scanAnchors(container))
110+
})
111+
112+
const models: AnchorModel[] = []
113+
const titlesToCheck: string[] = []
114+
115+
// Build models
116+
for (const { $el, title } of anchors) {
117+
if (!title) continue
118+
119+
const titlePrefixed = title.getPrefixedText()
120+
const counterpartTitle = getCounterpartTitle(title)
121+
122+
const model: AnchorModel = { $el, titlePrefixed }
123+
124+
if (counterpartTitle) {
125+
const counterpartPrefixed = counterpartTitle.getPrefixedText()
126+
model.counterpart = {
127+
title: counterpartTitle,
128+
prefixed: counterpartPrefixed,
129+
}
130+
titlesToCheck.push(counterpartPrefixed)
131+
}
132+
133+
models.push(model)
134+
}
135+
136+
const missingTitles = await getMissingTitles(titlesToCheck, ctx.api)
137+
138+
// Render
139+
for (const { $el, titlePrefixed, counterpart } of models) {
140+
if ($el.dataset.ipeInCatEditProcessed) continue
141+
$el.dataset.ipeInCatEditProcessed = '1'
142+
143+
const fragment = document.createDocumentFragment()
144+
145+
// 1. Edit button for current page
146+
fragment.appendChild(
147+
<EditButton
148+
isRedLink={false}
149+
onClick={(e) => {
150+
e.preventDefault()
151+
ctx.quickEdit.showModal({
152+
title: titlePrefixed,
153+
createOnly: false,
154+
})
155+
}}
156+
/>
157+
)
158+
159+
// 2. Counterpart link and its edit button
160+
if (counterpart) {
161+
const { title: counterpartTitle, prefixed: counterpartPrefixed } = counterpart
162+
const isMissing = missingTitles.has(counterpartPrefixed)
163+
164+
fragment.appendChild(
165+
<CounterpartLinks
166+
counterpartTitle={counterpartTitle}
167+
isMissing={isMissing}
168+
onCounterpartEditClick={(e) => {
169+
e.preventDefault()
170+
ctx.quickEdit.showModal({
171+
title: counterpartPrefixed,
172+
createOnly: isMissing,
173+
})
174+
}}
175+
/>
176+
)
177+
}
178+
179+
const parent = $el.parentNode
180+
if (parent) {
181+
parent.insertBefore(fragment, $el.nextSibling)
182+
}
183+
}
184+
})
185+
},
186+
})
187+
188+
const getCounterpartTitle = (title: IWikiTitle): IWikiTitle | null => {
189+
const subject = title.getSubjectPage()
190+
if (title.equals(subject)) {
191+
return title.getTalkPage()
192+
}
193+
return subject
194+
}
195+
196+
const getMissingTitles = async (
197+
titles: string[],
198+
api: any,
199+
chunkSize = 50
200+
): Promise<Set<string>> => {
201+
const missing = new Set<string>()
202+
const requests: Promise<void>[] = []
203+
204+
for (let i = 0; i < titles.length; i += chunkSize) {
205+
const chunk = titles.slice(i, i + chunkSize)
206+
207+
requests.push(
208+
api
209+
.post({
210+
action: 'query',
211+
titles: chunk.join('|'),
212+
format: 'json',
213+
formatversion: 2,
214+
})
215+
.then((response: any) => {
216+
const query = response.data?.query || response.query
217+
query?.pages?.forEach((page: any) => {
218+
if (page.missing) {
219+
missing.add(page.title)
220+
}
221+
})
222+
})
223+
.catch((e: unknown) => {
224+
console.error('[in-cat-edit] Failed to check page existence', e)
225+
})
226+
)
227+
}
228+
229+
await Promise.all(requests)
230+
return missing
231+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.ipe-in-cat-edit-counterpart {
2+
font-size: 0.9em;
3+
4+
.ipe-quick-edit {
5+
margin-left: 0.1em;
6+
font-size: 0.9em;
7+
}
8+
}

packages/in-cat-edit/tsconfig.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"include": ["src", "src/**/*.tsx", "../../common/**/*"],
3+
"compilerOptions": {
4+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5+
"composite": true,
6+
"baseUrl": ".",
7+
"target": "ES2020",
8+
"module": "ESNext",
9+
"moduleResolution": "bundler",
10+
"strict": true,
11+
"sourceMap": true,
12+
"jsx": "react-jsx",
13+
"jsxImportSource": "jsx-dom",
14+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
15+
"types": ["vite/client"],
16+
"skipLibCheck": true,
17+
"noEmit": true,
18+
"paths": {
19+
"~~/*": ["./../../common/*"]
20+
}
21+
}
22+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { resolve } from 'node:path'
2+
import { defineConfig } from 'vite'
3+
4+
export default defineConfig({
5+
build: {
6+
lib: {
7+
entry: 'src/index.tsx',
8+
formats: ['es'],
9+
fileName: () => 'index.mjs',
10+
cssFileName: 'style',
11+
},
12+
sourcemap: true,
13+
rollupOptions: {
14+
output: {
15+
inlineDynamicImports: false,
16+
},
17+
},
18+
},
19+
resolve: {
20+
alias: {
21+
'~~': resolve(import.meta.dirname, '../../common'),
22+
},
23+
},
24+
})

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)