@rmdes/indiekit-endpoint-webmention-io 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +113 -0
- package/lib/controllers/api.js +51 -0
- package/lib/controllers/blocklist.js +64 -0
- package/lib/controllers/dashboard.js +227 -0
- package/lib/controllers/sync-controller.js +51 -0
- package/lib/storage/blocklist.js +84 -0
- package/lib/storage/webmentions.js +252 -0
- package/lib/sync.js +289 -0
- package/lib/utils.js +115 -0
- package/locales/en.json +75 -0
- package/package.json +51 -0
- package/views/webmentions-blocklist.njk +193 -0
- package/views/webmentions.njk +222 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webmentions MongoDB storage
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { extractDomain, sanitiseHtml } from "../utils.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ensure indexes exist
|
|
9
|
+
* @param {object} collection - MongoDB collection
|
|
10
|
+
*/
|
|
11
|
+
export async function ensureIndexes(collection) {
|
|
12
|
+
await collection.createIndex({ wmId: 1 }, { unique: true });
|
|
13
|
+
await collection.createIndex({ wmTarget: 1, hidden: 1 });
|
|
14
|
+
await collection.createIndex({ sourceDomain: 1 });
|
|
15
|
+
await collection.createIndex({ wmReceived: -1 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Transform a JF2 webmention entry into our storage format
|
|
20
|
+
* @param {object} item - JF2 entry from webmention.io
|
|
21
|
+
* @returns {object} Document for MongoDB
|
|
22
|
+
*/
|
|
23
|
+
export function jf2ToDocument(item) {
|
|
24
|
+
let contentHtml = null;
|
|
25
|
+
let contentText = null;
|
|
26
|
+
|
|
27
|
+
if (item.content?.html) {
|
|
28
|
+
contentHtml = sanitiseHtml(item.content.html);
|
|
29
|
+
} else if (item.content?.text) {
|
|
30
|
+
contentHtml = `<p>${item.content.text}</p>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (item.content?.text) {
|
|
34
|
+
contentText = item.content.text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
wmId: item["wm-id"],
|
|
39
|
+
wmReceived: item["wm-received"] ? new Date(item["wm-received"]) : new Date(),
|
|
40
|
+
wmProperty: item["wm-property"],
|
|
41
|
+
wmTarget: item["wm-target"],
|
|
42
|
+
authorName: item.author?.name || null,
|
|
43
|
+
authorUrl: item.author?.url || null,
|
|
44
|
+
authorPhoto: item.author?.photo || null,
|
|
45
|
+
sourceUrl: item.url || null,
|
|
46
|
+
sourceDomain: extractDomain(item.author?.url || item.url || ""),
|
|
47
|
+
published: item.published ? new Date(item.published) : null,
|
|
48
|
+
contentHtml,
|
|
49
|
+
contentText,
|
|
50
|
+
name: item.name || null,
|
|
51
|
+
hidden: false,
|
|
52
|
+
hiddenAt: null,
|
|
53
|
+
hiddenReason: null,
|
|
54
|
+
syncedAt: new Date(),
|
|
55
|
+
raw: item,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert a stored document back to JF2 format for the public API
|
|
61
|
+
* @param {object} doc - MongoDB document
|
|
62
|
+
* @returns {object} JF2 entry
|
|
63
|
+
*/
|
|
64
|
+
export function documentToJf2(doc) {
|
|
65
|
+
const jf2 = {
|
|
66
|
+
type: "entry",
|
|
67
|
+
"wm-id": doc.wmId,
|
|
68
|
+
"wm-received": doc.wmReceived?.toISOString?.() || doc.wmReceived,
|
|
69
|
+
"wm-property": doc.wmProperty,
|
|
70
|
+
"wm-target": doc.wmTarget,
|
|
71
|
+
author: {
|
|
72
|
+
type: "card",
|
|
73
|
+
name: doc.authorName || "",
|
|
74
|
+
url: doc.authorUrl || "",
|
|
75
|
+
photo: doc.authorPhoto || "",
|
|
76
|
+
},
|
|
77
|
+
url: doc.sourceUrl || "",
|
|
78
|
+
published: doc.published?.toISOString?.() || doc.published || doc.wmReceived,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (doc.name) {
|
|
82
|
+
jf2.name = doc.name;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (doc.contentHtml || doc.contentText) {
|
|
86
|
+
jf2.content = {};
|
|
87
|
+
if (doc.contentHtml) jf2.content.html = doc.contentHtml;
|
|
88
|
+
if (doc.contentText) jf2.content.text = doc.contentText;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return jf2;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Upsert a webmention into MongoDB
|
|
96
|
+
* @param {object} collection - MongoDB collection
|
|
97
|
+
* @param {object} item - JF2 entry
|
|
98
|
+
* @returns {Promise<boolean>} true if inserted (new), false if updated
|
|
99
|
+
*/
|
|
100
|
+
export async function upsertWebmention(collection, item) {
|
|
101
|
+
const doc = jf2ToDocument(item);
|
|
102
|
+
const result = await collection.updateOne(
|
|
103
|
+
{ wmId: doc.wmId },
|
|
104
|
+
{
|
|
105
|
+
$setOnInsert: doc,
|
|
106
|
+
},
|
|
107
|
+
{ upsert: true },
|
|
108
|
+
);
|
|
109
|
+
return result.upsertedCount > 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get webmentions with filters
|
|
114
|
+
* @param {object} collection - MongoDB collection
|
|
115
|
+
* @param {object} options - Query options
|
|
116
|
+
* @returns {Promise<{items: Array, total: number}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function getWebmentions(collection, options = {}) {
|
|
119
|
+
const {
|
|
120
|
+
target,
|
|
121
|
+
wmProperty,
|
|
122
|
+
showHidden = false,
|
|
123
|
+
page = 0,
|
|
124
|
+
perPage = 20,
|
|
125
|
+
} = options;
|
|
126
|
+
|
|
127
|
+
const query = {};
|
|
128
|
+
|
|
129
|
+
if (!showHidden) {
|
|
130
|
+
query.hidden = { $ne: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (target) {
|
|
134
|
+
// Match with and without trailing slash
|
|
135
|
+
const targetClean = target.replace(/\/$/, "");
|
|
136
|
+
query.wmTarget = { $in: [targetClean, targetClean + "/"] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (wmProperty) {
|
|
140
|
+
query.wmProperty = wmProperty;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const total = await collection.countDocuments(query);
|
|
144
|
+
const items = await collection
|
|
145
|
+
.find(query)
|
|
146
|
+
.sort({ wmReceived: -1 })
|
|
147
|
+
.skip(page * perPage)
|
|
148
|
+
.limit(perPage)
|
|
149
|
+
.toArray();
|
|
150
|
+
|
|
151
|
+
return { items, total };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get webmention counts
|
|
156
|
+
* @param {object} collection - MongoDB collection
|
|
157
|
+
* @returns {Promise<{total: number, hidden: number, visible: number}>}
|
|
158
|
+
*/
|
|
159
|
+
export async function getWebmentionCounts(collection) {
|
|
160
|
+
const total = await collection.countDocuments({});
|
|
161
|
+
const hidden = await collection.countDocuments({ hidden: true });
|
|
162
|
+
return { total, hidden, visible: total - hidden };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get the highest wmId in the collection (for incremental sync)
|
|
167
|
+
* @param {object} collection - MongoDB collection
|
|
168
|
+
* @returns {Promise<number>} Highest wmId or 0
|
|
169
|
+
*/
|
|
170
|
+
export async function getMaxWmId(collection) {
|
|
171
|
+
const result = await collection
|
|
172
|
+
.find({})
|
|
173
|
+
.sort({ wmId: -1 })
|
|
174
|
+
.limit(1)
|
|
175
|
+
.toArray();
|
|
176
|
+
return result.length > 0 ? result[0].wmId : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Hide a webmention
|
|
181
|
+
* @param {object} collection - MongoDB collection
|
|
182
|
+
* @param {number} wmId - Webmention ID
|
|
183
|
+
* @param {string} reason - Reason ("manual", "blocklist", "privacy")
|
|
184
|
+
*/
|
|
185
|
+
export async function hideWebmention(collection, wmId, reason = "manual") {
|
|
186
|
+
await collection.updateOne(
|
|
187
|
+
{ wmId },
|
|
188
|
+
{ $set: { hidden: true, hiddenAt: new Date(), hiddenReason: reason } },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Unhide a webmention
|
|
194
|
+
* @param {object} collection - MongoDB collection
|
|
195
|
+
* @param {number} wmId - Webmention ID
|
|
196
|
+
*/
|
|
197
|
+
export async function unhideWebmention(collection, wmId) {
|
|
198
|
+
await collection.updateOne(
|
|
199
|
+
{ wmId },
|
|
200
|
+
{ $set: { hidden: false, hiddenAt: null, hiddenReason: null } },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Hide all webmentions from a domain
|
|
206
|
+
* @param {object} collection - MongoDB collection
|
|
207
|
+
* @param {string} domain - Domain to hide
|
|
208
|
+
* @param {string} reason - Reason
|
|
209
|
+
* @returns {Promise<number>} Number of mentions hidden
|
|
210
|
+
*/
|
|
211
|
+
export async function hideByDomain(collection, domain, reason = "blocklist") {
|
|
212
|
+
const result = await collection.updateMany(
|
|
213
|
+
{ sourceDomain: domain, hidden: { $ne: true } },
|
|
214
|
+
{ $set: { hidden: true, hiddenAt: new Date(), hiddenReason: reason } },
|
|
215
|
+
);
|
|
216
|
+
return result.modifiedCount;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Unhide webmentions from a domain that were hidden by blocklist
|
|
221
|
+
* @param {object} collection - MongoDB collection
|
|
222
|
+
* @param {string} domain - Domain to unhide
|
|
223
|
+
* @returns {Promise<number>} Number of mentions unhidden
|
|
224
|
+
*/
|
|
225
|
+
export async function unhideByDomain(collection, domain) {
|
|
226
|
+
const result = await collection.updateMany(
|
|
227
|
+
{ sourceDomain: domain, hiddenReason: "blocklist" },
|
|
228
|
+
{ $set: { hidden: false, hiddenAt: null, hiddenReason: null } },
|
|
229
|
+
);
|
|
230
|
+
return result.modifiedCount;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Permanently delete all webmentions from a domain (for privacy removal)
|
|
235
|
+
* @param {object} collection - MongoDB collection
|
|
236
|
+
* @param {string} domain - Domain
|
|
237
|
+
* @returns {Promise<number>} Number deleted
|
|
238
|
+
*/
|
|
239
|
+
export async function deleteByDomain(collection, domain) {
|
|
240
|
+
const result = await collection.deleteMany({ sourceDomain: domain });
|
|
241
|
+
return result.deletedCount;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Delete all webmentions (for full re-sync)
|
|
246
|
+
* @param {object} collection - MongoDB collection
|
|
247
|
+
* @returns {Promise<number>} Number deleted
|
|
248
|
+
*/
|
|
249
|
+
export async function deleteAll(collection) {
|
|
250
|
+
const result = await collection.deleteMany({});
|
|
251
|
+
return result.deletedCount;
|
|
252
|
+
}
|
package/lib/sync.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background sync from webmention.io
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { extractDomain } from "./utils.js";
|
|
6
|
+
import {
|
|
7
|
+
ensureIndexes,
|
|
8
|
+
upsertWebmention,
|
|
9
|
+
getMaxWmId,
|
|
10
|
+
deleteAll,
|
|
11
|
+
hideByDomain,
|
|
12
|
+
} from "./storage/webmentions.js";
|
|
13
|
+
import {
|
|
14
|
+
ensureBlocklistIndexes,
|
|
15
|
+
getBlockedDomainSet,
|
|
16
|
+
} from "./storage/blocklist.js";
|
|
17
|
+
|
|
18
|
+
let syncInterval = null;
|
|
19
|
+
let syncState = {
|
|
20
|
+
lastSync: null,
|
|
21
|
+
syncing: false,
|
|
22
|
+
lastError: null,
|
|
23
|
+
mentionsAdded: 0,
|
|
24
|
+
mentionsFiltered: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get current sync state
|
|
29
|
+
* @returns {object}
|
|
30
|
+
*/
|
|
31
|
+
export function getSyncState() {
|
|
32
|
+
return { ...syncState };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start background sync
|
|
37
|
+
* @param {object} Indiekit - Indiekit instance
|
|
38
|
+
* @param {object} options - Plugin options
|
|
39
|
+
*/
|
|
40
|
+
export function startSync(Indiekit, options) {
|
|
41
|
+
const intervalMs = options.syncInterval || 900_000; // 15 minutes
|
|
42
|
+
|
|
43
|
+
console.log(
|
|
44
|
+
`[Webmentions] Starting background sync with ${intervalMs / 60_000}min interval`,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Initial sync after delay
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
runSync(Indiekit, options).catch((err) => {
|
|
50
|
+
console.error("[Webmentions] Initial sync error:", err.message);
|
|
51
|
+
});
|
|
52
|
+
}, 10_000);
|
|
53
|
+
|
|
54
|
+
// Recurring sync
|
|
55
|
+
syncInterval = setInterval(() => {
|
|
56
|
+
runSync(Indiekit, options).catch((err) => {
|
|
57
|
+
console.error("[Webmentions] Sync error:", err.message);
|
|
58
|
+
});
|
|
59
|
+
}, intervalMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Stop background sync
|
|
64
|
+
*/
|
|
65
|
+
export function stopSync() {
|
|
66
|
+
if (syncInterval) {
|
|
67
|
+
clearInterval(syncInterval);
|
|
68
|
+
syncInterval = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run a single incremental sync cycle
|
|
74
|
+
* @param {object} dbOrIndiekit - Database or Indiekit instance
|
|
75
|
+
* @param {object} options - Plugin options
|
|
76
|
+
* @returns {Promise<object>}
|
|
77
|
+
*/
|
|
78
|
+
export async function runSync(dbOrIndiekit, options) {
|
|
79
|
+
const db = dbOrIndiekit.database || dbOrIndiekit;
|
|
80
|
+
if (!db || typeof db.collection !== "function") {
|
|
81
|
+
syncState.lastError = "No database available";
|
|
82
|
+
return { error: syncState.lastError };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (syncState.syncing) {
|
|
86
|
+
return { error: "Sync already in progress" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
syncState.syncing = true;
|
|
90
|
+
syncState.lastError = null;
|
|
91
|
+
syncState.mentionsAdded = 0;
|
|
92
|
+
syncState.mentionsFiltered = 0;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const wmCollection = db.collection("webmentions");
|
|
96
|
+
const blockCollection = db.collection("webmentionBlocklist");
|
|
97
|
+
|
|
98
|
+
await ensureIndexes(wmCollection);
|
|
99
|
+
await ensureBlocklistIndexes(blockCollection);
|
|
100
|
+
|
|
101
|
+
// Get highest wmId for incremental sync
|
|
102
|
+
const sinceId = await getMaxWmId(wmCollection);
|
|
103
|
+
|
|
104
|
+
// Get blocked domains
|
|
105
|
+
const blockedDomains = await getBlockedDomainSet(blockCollection);
|
|
106
|
+
|
|
107
|
+
// Fetch pages from webmention.io
|
|
108
|
+
let page = 0;
|
|
109
|
+
let hasMore = true;
|
|
110
|
+
const perPage = 100;
|
|
111
|
+
|
|
112
|
+
while (hasMore) {
|
|
113
|
+
const items = await fetchPage(options, { page, perPage, sinceId });
|
|
114
|
+
|
|
115
|
+
if (!items || items.length === 0) {
|
|
116
|
+
hasMore = false;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
const domain = extractDomain(item.author?.url || item.url || "");
|
|
122
|
+
|
|
123
|
+
if (domain && blockedDomains.has(domain)) {
|
|
124
|
+
syncState.mentionsFiltered++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isNew = await upsertWebmention(wmCollection, item);
|
|
129
|
+
if (isNew) {
|
|
130
|
+
syncState.mentionsAdded++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
page++;
|
|
135
|
+
|
|
136
|
+
// Rate limit: small delay between pages
|
|
137
|
+
if (items.length >= perPage) {
|
|
138
|
+
await delay(500);
|
|
139
|
+
} else {
|
|
140
|
+
hasMore = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
syncState.lastSync = new Date();
|
|
145
|
+
syncState.syncing = false;
|
|
146
|
+
|
|
147
|
+
console.log(
|
|
148
|
+
`[Webmentions] Sync complete: ${syncState.mentionsAdded} new, ${syncState.mentionsFiltered} filtered`,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
mentionsAdded: syncState.mentionsAdded,
|
|
153
|
+
mentionsFiltered: syncState.mentionsFiltered,
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
syncState.lastError = error.message;
|
|
157
|
+
syncState.syncing = false;
|
|
158
|
+
console.error("[Webmentions] Sync failed:", error.message);
|
|
159
|
+
return { error: error.message };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Run a full re-sync (clear + fetch all)
|
|
165
|
+
* @param {object} dbOrIndiekit - Database or Indiekit instance
|
|
166
|
+
* @param {object} options - Plugin options
|
|
167
|
+
* @returns {Promise<object>}
|
|
168
|
+
*/
|
|
169
|
+
export async function runFullSync(dbOrIndiekit, options) {
|
|
170
|
+
const db = dbOrIndiekit.database || dbOrIndiekit;
|
|
171
|
+
if (!db || typeof db.collection !== "function") {
|
|
172
|
+
return { error: "No database available" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (syncState.syncing) {
|
|
176
|
+
return { error: "Sync already in progress" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
syncState.syncing = true;
|
|
180
|
+
syncState.lastError = null;
|
|
181
|
+
syncState.mentionsAdded = 0;
|
|
182
|
+
syncState.mentionsFiltered = 0;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const wmCollection = db.collection("webmentions");
|
|
186
|
+
const blockCollection = db.collection("webmentionBlocklist");
|
|
187
|
+
|
|
188
|
+
await ensureIndexes(wmCollection);
|
|
189
|
+
await ensureBlocklistIndexes(blockCollection);
|
|
190
|
+
|
|
191
|
+
// Clear all existing webmentions
|
|
192
|
+
const deleted = await deleteAll(wmCollection);
|
|
193
|
+
console.log(`[Webmentions] Full sync: cleared ${deleted} existing mentions`);
|
|
194
|
+
|
|
195
|
+
// Get blocked domains
|
|
196
|
+
const blockedDomains = await getBlockedDomainSet(blockCollection);
|
|
197
|
+
|
|
198
|
+
// Fetch ALL pages from webmention.io (no sinceId)
|
|
199
|
+
let page = 0;
|
|
200
|
+
let hasMore = true;
|
|
201
|
+
const perPage = 100;
|
|
202
|
+
|
|
203
|
+
while (hasMore) {
|
|
204
|
+
const items = await fetchPage(options, { page, perPage });
|
|
205
|
+
|
|
206
|
+
if (!items || items.length === 0) {
|
|
207
|
+
hasMore = false;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const item of items) {
|
|
212
|
+
const domain = extractDomain(item.author?.url || item.url || "");
|
|
213
|
+
|
|
214
|
+
if (domain && blockedDomains.has(domain)) {
|
|
215
|
+
syncState.mentionsFiltered++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const isNew = await upsertWebmention(wmCollection, item);
|
|
220
|
+
if (isNew) {
|
|
221
|
+
syncState.mentionsAdded++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
page++;
|
|
226
|
+
|
|
227
|
+
// Rate limit between pages
|
|
228
|
+
if (items.length >= perPage) {
|
|
229
|
+
await delay(1000);
|
|
230
|
+
} else {
|
|
231
|
+
hasMore = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
syncState.lastSync = new Date();
|
|
236
|
+
syncState.syncing = false;
|
|
237
|
+
|
|
238
|
+
console.log(
|
|
239
|
+
`[Webmentions] Full sync complete: ${syncState.mentionsAdded} imported, ${syncState.mentionsFiltered} filtered`,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
mentionsAdded: syncState.mentionsAdded,
|
|
244
|
+
mentionsFiltered: syncState.mentionsFiltered,
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
syncState.lastError = error.message;
|
|
248
|
+
syncState.syncing = false;
|
|
249
|
+
console.error("[Webmentions] Full sync failed:", error.message);
|
|
250
|
+
return { error: error.message };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Fetch a single page from webmention.io API
|
|
256
|
+
* @param {object} options - Plugin options (token, domain)
|
|
257
|
+
* @param {object} params - Fetch params (page, perPage, sinceId)
|
|
258
|
+
* @returns {Promise<Array>} Array of JF2 entries
|
|
259
|
+
*/
|
|
260
|
+
async function fetchPage(options, params = {}) {
|
|
261
|
+
const url = new URL("https://webmention.io/api/mentions.jf2");
|
|
262
|
+
url.searchParams.set("token", options.token);
|
|
263
|
+
url.searchParams.set("domain", options.domain);
|
|
264
|
+
url.searchParams.set("per-page", String(params.perPage || 100));
|
|
265
|
+
|
|
266
|
+
if (params.page) {
|
|
267
|
+
url.searchParams.set("page", String(params.page));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (params.sinceId) {
|
|
271
|
+
url.searchParams.set("since_id", String(params.sinceId));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const response = await fetch(url.href, {
|
|
275
|
+
headers: { accept: "application/json" },
|
|
276
|
+
signal: AbortSignal.timeout(15_000),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!response.ok) {
|
|
280
|
+
throw new Error(`webmention.io returned ${response.status}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const body = await response.json();
|
|
284
|
+
return body?.children || [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function delay(ms) {
|
|
288
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
289
|
+
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import sanitize from "sanitize-html";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get mention type from `wm-property`
|
|
5
|
+
* @param {string} wmProperty - Webmention.io `wm-property` value
|
|
6
|
+
* @returns {string} Icon name
|
|
7
|
+
*/
|
|
8
|
+
export const getMentionType = (wmProperty) => {
|
|
9
|
+
switch (true) {
|
|
10
|
+
case wmProperty === "in-reply-to": {
|
|
11
|
+
return "reply";
|
|
12
|
+
}
|
|
13
|
+
case wmProperty === "like-of": {
|
|
14
|
+
return "like";
|
|
15
|
+
}
|
|
16
|
+
case wmProperty === "repost-of": {
|
|
17
|
+
return "repost";
|
|
18
|
+
}
|
|
19
|
+
case wmProperty === "bookmark-of": {
|
|
20
|
+
return "bookmark";
|
|
21
|
+
}
|
|
22
|
+
case wmProperty === "rsvp": {
|
|
23
|
+
return "rsvp";
|
|
24
|
+
}
|
|
25
|
+
default: {
|
|
26
|
+
return "mention";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const upperFirst = (string) => {
|
|
32
|
+
return String(string).charAt(0).toUpperCase() + String(string).slice(1);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get mention title
|
|
37
|
+
* @param {object} jf2 - JF2
|
|
38
|
+
* @returns {string} Mention title
|
|
39
|
+
*/
|
|
40
|
+
export const getMentionTitle = (jf2) => {
|
|
41
|
+
let type = getMentionType(jf2["wm-property"]);
|
|
42
|
+
type = upperFirst(type).replace("Rsvp", "RSVP");
|
|
43
|
+
|
|
44
|
+
return jf2.name || type;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get author name
|
|
49
|
+
* @param {object} jf2 - JF2
|
|
50
|
+
* @returns {string} Author name or URL fallback
|
|
51
|
+
*/
|
|
52
|
+
export const getAuthorName = (jf2) => {
|
|
53
|
+
if (jf2.author?.name) return jf2.author.name;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
let url = jf2.author?.url || jf2.url;
|
|
57
|
+
url = new URL(url);
|
|
58
|
+
return url.hostname + url.pathname.replace(/\/$/, "");
|
|
59
|
+
} catch {
|
|
60
|
+
return "Unknown";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalise paragraphs
|
|
66
|
+
* @param {string} html - HTML
|
|
67
|
+
* @returns {string} HTML with normalised paragraphs
|
|
68
|
+
*/
|
|
69
|
+
export const normaliseParagraphs = (html) => {
|
|
70
|
+
html = `<p>${html}</p>`;
|
|
71
|
+
html = html.replaceAll(/<br\s*\/?>\s*<br\s*\/?>/g, "</p><p>");
|
|
72
|
+
return html;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sanitise incoming mention HTML
|
|
77
|
+
* @param {string} html - HTML
|
|
78
|
+
* @returns {string} Sanitised HTML
|
|
79
|
+
*/
|
|
80
|
+
export const sanitiseHtml = (html) => {
|
|
81
|
+
html = normaliseParagraphs(html);
|
|
82
|
+
html = sanitize(html, {
|
|
83
|
+
exclusiveFilter: function (frame) {
|
|
84
|
+
return (
|
|
85
|
+
(frame.tag === "a" &&
|
|
86
|
+
frame.attribs?.href?.includes("brid.gy") &&
|
|
87
|
+
!frame.text.trim()) ||
|
|
88
|
+
(frame.tag === "p" && !frame.text.trim())
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
transformTags: {
|
|
92
|
+
h1: "h3",
|
|
93
|
+
h2: "h4",
|
|
94
|
+
h3: "h5",
|
|
95
|
+
h4: "h6",
|
|
96
|
+
h5: "h6",
|
|
97
|
+
h6: "h6",
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return html;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract domain from a URL string
|
|
106
|
+
* @param {string} url - URL
|
|
107
|
+
* @returns {string|null} Domain or null
|
|
108
|
+
*/
|
|
109
|
+
export const extractDomain = (url) => {
|
|
110
|
+
try {
|
|
111
|
+
return new URL(url).hostname;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
};
|