@rmdes/indiekit-endpoint-microsub 1.0.27 → 1.0.29

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/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # @rmdes/indiekit-endpoint-microsub
2
+
3
+ A comprehensive Microsub social reader plugin for Indiekit. Subscribe to feeds (RSS, Atom, JSON Feed, h-feed), organize them into channels, and read posts in a unified timeline interface with a built-in web reader UI.
4
+
5
+ ## Features
6
+
7
+ - **Microsub Protocol**: Full implementation of the [Microsub spec](https://indieweb.org/Microsub)
8
+ - **Multi-Format Feeds**: RSS, Atom, JSON Feed, h-feed (microformats)
9
+ - **Smart Polling**: Adaptive tiered polling (2 minutes to 17+ hours) based on update frequency
10
+ - **Real-Time Updates**: WebSub (PubSubHubbub) support for instant notifications
11
+ - **Web Reader UI**: Built-in reader interface with channel navigation and timeline view
12
+ - **Feed Discovery**: Automatic discovery of feeds from website URLs
13
+ - **Read State**: Per-user read tracking with automatic cleanup
14
+ - **Compose Interface**: Post replies, likes, reposts, and bookmarks via Micropub
15
+ - **Webmention Support**: Receive webmentions in your notifications channel
16
+ - **Media Proxy**: Privacy-friendly image proxying
17
+ - **OPML Export**: Export your subscriptions as OPML
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @rmdes/indiekit-endpoint-microsub
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Add to your Indiekit config:
28
+
29
+ ```javascript
30
+ import MicrosubEndpoint from "@rmdes/indiekit-endpoint-microsub";
31
+
32
+ export default {
33
+ plugins: [
34
+ new MicrosubEndpoint({
35
+ mountPath: "/microsub", // Default mount path
36
+ }),
37
+ ],
38
+ };
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Web Reader UI
44
+
45
+ Navigate to `/microsub/reader` in your Indiekit installation to access the web interface.
46
+
47
+ **Channels**: Organize feeds into channels (Technology, News, Friends, etc.)
48
+ - Create new channels
49
+ - Configure content filters (exclude types, regex patterns)
50
+ - Reorder channels
51
+
52
+ **Feeds**: Manage subscriptions within each channel
53
+ - Subscribe to feeds by URL
54
+ - Search and discover feeds from websites
55
+ - Edit or rediscover feed URLs
56
+ - Force refresh feeds
57
+ - View feed health status
58
+
59
+ **Timeline**: Read posts from subscribed feeds
60
+ - Paginated timeline view
61
+ - Mark individual items or all items as read
62
+ - View read items separately
63
+ - Click through to original posts
64
+
65
+ **Compose**: Create posts via Micropub
66
+ - Reply to posts
67
+ - Like posts
68
+ - Repost posts
69
+ - Bookmark posts
70
+ - Include syndication targets
71
+
72
+ ### Microsub API
73
+
74
+ Compatible with Microsub clients like [Indigenous](https://indigenous.realize.be/) and [Monocle](https://monocle.p3k.io/).
75
+
76
+ **Endpoint:** Your Indiekit URL + `/microsub`
77
+
78
+ **Supported Actions:**
79
+ - `channels` - List, create, update, delete, reorder channels
80
+ - `timeline` - Get timeline items (paginated)
81
+ - `follow` - Subscribe to a feed
82
+ - `unfollow` - Unsubscribe from a feed
83
+ - `mute` - Mute URLs
84
+ - `unmute` - Unmute URLs
85
+ - `block` - Block authors
86
+ - `unblock` - Unblock authors
87
+ - `search` - Discover feeds from URL
88
+ - `preview` - Preview feed before subscribing
89
+
90
+ **Example:**
91
+
92
+ ```bash
93
+ # List channels
94
+ curl "https://your-site.example/microsub?action=channels" \
95
+ -H "Authorization: Bearer YOUR_TOKEN"
96
+
97
+ # Get timeline for channel
98
+ curl "https://your-site.example/microsub?action=timeline&channel=CHANNEL_UID" \
99
+ -H "Authorization: Bearer YOUR_TOKEN"
100
+
101
+ # Subscribe to feed
102
+ curl "https://your-site.example/microsub" \
103
+ -X POST \
104
+ -H "Authorization: Bearer YOUR_TOKEN" \
105
+ -d "action=follow&channel=CHANNEL_UID&url=https://example.com/feed"
106
+ ```
107
+
108
+ ## Feed Polling
109
+
110
+ Feeds are polled using an adaptive tiered system:
111
+
112
+ - **Tier 0**: 1 minute (very active feeds)
113
+ - **Tier 1**: 2 minutes (active feeds)
114
+ - **Tier 2**: 4 minutes
115
+ - **Tier 3**: 8 minutes
116
+ - ...
117
+ - **Tier 10**: ~17 hours (inactive feeds)
118
+
119
+ Tiers adjust automatically:
120
+ - Feed updates → decrease tier (faster polling)
121
+ - No changes for 2+ fetches → increase tier (slower polling)
122
+
123
+ WebSub-enabled feeds receive instant updates when available.
124
+
125
+ ## Read State Management
126
+
127
+ Read items are tracked per user. To prevent database bloat, only the last 30 read items per channel are kept. Unread items are never deleted.
128
+
129
+ Cleanup runs automatically:
130
+ - On server startup
131
+ - After marking items read
132
+
133
+ ## Integration with Other Plugins
134
+
135
+ ### Blogroll Plugin
136
+
137
+ If `@rmdes/indiekit-endpoint-blogroll` is installed, Microsub will automatically sync feed subscriptions:
138
+ - Subscribe to feed → adds to blogroll
139
+ - Unsubscribe → soft-deletes from blogroll
140
+
141
+ ### Micropub Plugin
142
+
143
+ The compose interface posts via Micropub. Ensure `@indiekit/endpoint-micropub` is configured.
144
+
145
+ ## OPML Export
146
+
147
+ Export your subscriptions:
148
+
149
+ ```
150
+ GET /microsub/reader/opml
151
+ ```
152
+
153
+ Returns OPML XML with all subscribed feeds organized by channel.
154
+
155
+ ## Webmentions
156
+
157
+ The plugin accepts webmentions at `/microsub/webmention`. Received webmentions appear in the special "Notifications" channel.
158
+
159
+ To advertise your webmention endpoint, add to your site's `<head>`:
160
+
161
+ ```html
162
+ <link rel="webmention" href="https://your-site.example/microsub/webmention" />
163
+ ```
164
+
165
+ ## Media Proxy
166
+
167
+ External images are proxied through `/microsub/media/:hash` for privacy and caching. This prevents your IP address from being sent to third-party image hosts.
168
+
169
+ ## API Response Format
170
+
171
+ All API responses follow the Microsub spec. Timeline items use the [jf2 format](https://jf2.spec.indieweb.org/).
172
+
173
+ **Example timeline response:**
174
+
175
+ ```json
176
+ {
177
+ "items": [
178
+ {
179
+ "type": "entry",
180
+ "uid": "https://example.com/post/123",
181
+ "url": "https://example.com/post/123",
182
+ "published": "2026-02-13T12:00:00.000Z",
183
+ "name": "Post Title",
184
+ "content": {
185
+ "text": "Plain text content",
186
+ "html": "<p>HTML content</p>"
187
+ },
188
+ "author": {
189
+ "name": "Author Name",
190
+ "url": "https://author.example/",
191
+ "photo": "https://author.example/photo.jpg"
192
+ },
193
+ "_id": "507f1f77bcf86cd799439011",
194
+ "_is_read": false
195
+ }
196
+ ],
197
+ "paging": {
198
+ "after": "cursor-string"
199
+ }
200
+ }
201
+ ```
202
+
203
+ ## Database Collections
204
+
205
+ The plugin creates these MongoDB collections:
206
+
207
+ - `microsub_channels` - User channels
208
+ - `microsub_feeds` - Feed subscriptions with polling metadata
209
+ - `microsub_items` - Timeline items (posts)
210
+ - `microsub_notifications` - Notifications channel items
211
+ - `microsub_muted` - Muted URLs
212
+ - `microsub_blocked` - Blocked authors
213
+
214
+ ## Troubleshooting
215
+
216
+ ### Feeds not updating
217
+
218
+ - Check the feed's `nextFetchAt` time in the admin UI
219
+ - Use "Force Refresh" button to poll immediately
220
+ - Try "Rediscover" to find the correct feed URL
221
+
222
+ ### "Unable to detect feed type" error
223
+
224
+ - The URL may not be a valid feed
225
+ - Try using the search feature to discover feeds from the homepage
226
+ - Check if the feed requires authentication
227
+
228
+ ### Items disappearing after marking read
229
+
230
+ This is normal behavior - only the last 30 read items per channel are kept to prevent database bloat. Unread items are never deleted.
231
+
232
+ ### Duplicate items
233
+
234
+ Deduplication is based on the feed's GUID/URL. If a feed doesn't provide stable GUIDs, duplicates may appear.
235
+
236
+ ## Contributing
237
+
238
+ Issues and pull requests welcome at [github.com/rmdes/indiekit-endpoint-microsub](https://github.com/rmdes/indiekit-endpoint-microsub)
239
+
240
+ ## License
241
+
242
+ MIT
243
+
244
+ ## Credits
245
+
246
+ Built by [Ricardo Mendes](https://rmendes.net) for the IndieWeb community.
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
2
3
 
3
4
  import express from "express";
4
5
 
@@ -29,6 +30,14 @@ export default class MicrosubEndpoint {
29
30
  this.mountPath = this.options.mountPath;
30
31
  }
31
32
 
33
+ /**
34
+ * Locales directory path
35
+ * @returns {string} Path to locales directory
36
+ */
37
+ get localesDirectory() {
38
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "locales");
39
+ }
40
+
32
41
  /**
33
42
  * Navigation items for Indiekit admin
34
43
  * @returns {object} Navigation item configuration
@@ -54,7 +54,7 @@ export async function block(request, response) {
54
54
  await collection.insertOne({
55
55
  userId,
56
56
  url,
57
- createdAt: new Date(),
57
+ createdAt: new Date().toISOString(),
58
58
  });
59
59
  }
60
60
 
@@ -85,7 +85,7 @@ export async function mute(request, response) {
85
85
  userId,
86
86
  channelId,
87
87
  url,
88
- createdAt: new Date(),
88
+ createdAt: new Date().toISOString(),
89
89
  });
90
90
  }
91
91
 
@@ -74,8 +74,8 @@ export async function createChannel(application, { name, userId }) {
74
74
  excludeTypes: [],
75
75
  excludeRegex: undefined,
76
76
  },
77
- createdAt: new Date(),
78
- updatedAt: new Date(),
77
+ createdAt: new Date().toISOString(),
78
+ updatedAt: new Date().toISOString(),
79
79
  };
80
80
 
81
81
  await collection.insertOne(channel);
@@ -185,7 +185,7 @@ export async function updateChannel(application, uid, updates, userId) {
185
185
  {
186
186
  $set: {
187
187
  ...updates,
188
- updatedAt: new Date(),
188
+ updatedAt: new Date().toISOString(),
189
189
  },
190
190
  },
191
191
  { returnDocument: "after" },
@@ -242,7 +242,7 @@ export async function reorderChannels(application, channelUids, userId) {
242
242
  const operations = channelUids.map((uid, index) => ({
243
243
  updateOne: {
244
244
  filter: userId ? { uid, userId } : { uid },
245
- update: { $set: { order: index, updatedAt: new Date() } },
245
+ update: { $set: { order: index, updatedAt: new Date().toISOString() } },
246
246
  },
247
247
  }));
248
248
 
@@ -298,8 +298,8 @@ export async function ensureNotificationsChannel(application, userId) {
298
298
  excludeTypes: [],
299
299
  excludeRegex: undefined,
300
300
  },
301
- createdAt: new Date(),
302
- updatedAt: new Date(),
301
+ createdAt: new Date().toISOString(),
302
+ updatedAt: new Date().toISOString(),
303
303
  };
304
304
 
305
305
  await collection.insertOne(channel);
@@ -45,11 +45,11 @@ export async function createFeed(
45
45
  photo: photo || undefined,
46
46
  tier: 1, // Start at tier 1 (2 minutes)
47
47
  unmodified: 0,
48
- nextFetchAt: new Date(), // Fetch immediately
48
+ nextFetchAt: new Date(), // Fetch immediately (kept as Date for query compatibility)
49
49
  lastFetchedAt: undefined,
50
50
  websub: undefined, // Will be populated if hub is discovered
51
- createdAt: new Date(),
52
- updatedAt: new Date(),
51
+ createdAt: new Date().toISOString(),
52
+ updatedAt: new Date().toISOString(),
53
53
  };
54
54
 
55
55
  await collection.insertOne(feed);
@@ -114,7 +114,7 @@ export async function updateFeed(application, id, updates) {
114
114
  {
115
115
  $set: {
116
116
  ...updates,
117
- updatedAt: new Date(),
117
+ updatedAt: new Date().toISOString(),
118
118
  },
119
119
  },
120
120
  { returnDocument: "after" },
@@ -227,15 +227,15 @@ export async function updateFeedAfterFetch(
227
227
  updateData = {
228
228
  tier,
229
229
  unmodified,
230
- nextFetchAt,
231
- lastFetchedAt: new Date(),
232
- updatedAt: new Date(),
230
+ nextFetchAt, // Kept as Date for query compatibility
231
+ lastFetchedAt: new Date().toISOString(),
232
+ updatedAt: new Date().toISOString(),
233
233
  };
234
234
  } else {
235
235
  updateData = {
236
236
  ...extra,
237
- lastFetchedAt: new Date(),
238
- updatedAt: new Date(),
237
+ lastFetchedAt: new Date().toISOString(),
238
+ updatedAt: new Date().toISOString(),
239
239
  };
240
240
  }
241
241
 
@@ -280,7 +280,7 @@ export async function updateFeedWebsub(application, id, websub) {
280
280
  {
281
281
  $set: {
282
282
  websub: websubData,
283
- updatedAt: new Date(),
283
+ updatedAt: new Date().toISOString(),
284
284
  },
285
285
  },
286
286
  { returnDocument: "after" },
@@ -314,12 +314,12 @@ export async function updateFeedStatus(application, id, status) {
314
314
  const objectId = typeof id === "string" ? new ObjectId(id) : id;
315
315
 
316
316
  const updateFields = {
317
- updatedAt: new Date(),
317
+ updatedAt: new Date().toISOString(),
318
318
  };
319
319
 
320
320
  if (status.success) {
321
321
  updateFields.status = "active";
322
- updateFields.lastSuccessAt = new Date();
322
+ updateFields.lastSuccessAt = new Date().toISOString();
323
323
  updateFields.consecutiveErrors = 0;
324
324
  updateFields.lastError = undefined;
325
325
  updateFields.lastErrorAt = undefined;
@@ -330,7 +330,7 @@ export async function updateFeedStatus(application, id, status) {
330
330
  } else {
331
331
  updateFields.status = "error";
332
332
  updateFields.lastError = status.error;
333
- updateFields.lastErrorAt = new Date();
333
+ updateFields.lastErrorAt = new Date().toISOString();
334
334
  }
335
335
 
336
336
  // Use $set for most fields, $inc for consecutiveErrors on failure
@@ -49,8 +49,8 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
49
49
  name: item.name || undefined,
50
50
  content: item.content || undefined,
51
51
  summary: item.summary || undefined,
52
- published: item.published ? new Date(item.published) : new Date(),
53
- updated: item.updated ? new Date(item.updated) : undefined,
52
+ published: item.published ? new Date(item.published) : new Date(), // Keep as Date for query compatibility
53
+ updated: item.updated ? new Date(item.updated) : undefined, // Keep as Date for query compatibility
54
54
  author: item.author || undefined,
55
55
  category: item.category || [],
56
56
  photo: item.photo || [],
@@ -62,7 +62,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
62
62
  inReplyTo: item["in-reply-to"] || item.inReplyTo || [],
63
63
  source: item._source || undefined,
64
64
  readBy: [], // Array of user IDs who have read this item
65
- createdAt: new Date(),
65
+ createdAt: new Date().toISOString(),
66
66
  };
67
67
 
68
68
  await collection.insertOne(document);
@@ -182,7 +182,7 @@ function transformToJf2(item, userId) {
182
182
  type: item.type,
183
183
  uid: item.uid,
184
184
  url: item.url,
185
- published: item.published?.toISOString(),
185
+ published: item.published?.toISOString(), // Convert Date to ISO string
186
186
  _id: item._id.toString(),
187
187
  _is_read: userId ? item.readBy?.includes(userId) : false,
188
188
  };
@@ -191,7 +191,7 @@ function transformToJf2(item, userId) {
191
191
  if (item.name) jf2.name = item.name;
192
192
  if (item.content) jf2.content = item.content;
193
193
  if (item.summary) jf2.summary = item.summary;
194
- if (item.updated) jf2.updated = item.updated.toISOString();
194
+ if (item.updated) jf2.updated = item.updated.toISOString(); // Convert Date to ISO string
195
195
  if (item.author) jf2.author = normalizeAuthor(item.author);
196
196
  if (item.category?.length > 0) jf2.category = item.category;
197
197
 
@@ -61,10 +61,10 @@ export async function processWebmention(application, source, target, userId) {
61
61
  url: verification.url,
62
62
  published: verification.published
63
63
  ? new Date(verification.published)
64
- : new Date(),
64
+ : new Date(), // Keep as Date for query compatibility
65
65
  verified: true,
66
66
  readBy: [],
67
- updatedAt: new Date(),
67
+ updatedAt: new Date().toISOString(),
68
68
  };
69
69
 
70
70
  if (existing) {
@@ -73,7 +73,7 @@ export async function processWebmention(application, source, target, userId) {
73
73
  notification._id = existing._id;
74
74
  } else {
75
75
  // Insert new notification
76
- notification.createdAt = new Date();
76
+ notification.createdAt = new Date().toISOString();
77
77
  await collection.insertOne(notification);
78
78
  }
79
79
 
@@ -190,7 +190,7 @@ function transformNotification(notification, userId) {
190
190
  type: "entry",
191
191
  uid: notification._id?.toString(),
192
192
  url: notification.url || notification.source,
193
- published: notification.published?.toISOString(),
193
+ published: notification.published?.toISOString(), // Convert Date to ISO string
194
194
  author: notification.author,
195
195
  content: notification.content,
196
196
  _source: notification.source,
@@ -0,0 +1,104 @@
1
+ {
2
+ "microsub": {
3
+ "reader": {
4
+ "title": "Leser",
5
+ "empty": "Keine Elemente zum Anzeigen",
6
+ "markAllRead": "Alle als gelesen markieren",
7
+ "showRead": "Gelesene anzeigen ({{count}})",
8
+ "hideRead": "Gelesene Elemente ausblenden",
9
+ "allRead": "Alles aufgeholt!",
10
+ "newer": "Neuer",
11
+ "older": "Älter"
12
+ },
13
+ "channels": {
14
+ "title": "Kanäle",
15
+ "name": "Kanalname",
16
+ "new": "Neuer Kanal",
17
+ "create": "Kanal erstellen",
18
+ "delete": "Kanal löschen",
19
+ "settings": "Kanaleinstellungen",
20
+ "empty": "Noch keine Kanäle. Erstellen Sie einen, um zu beginnen.",
21
+ "notifications": "Benachrichtigungen"
22
+ },
23
+ "timeline": {
24
+ "title": "Zeitleiste",
25
+ "empty": "Keine Elemente in diesem Kanal",
26
+ "markRead": "Als gelesen markieren",
27
+ "markUnread": "Als ungelesen markieren",
28
+ "remove": "Entfernen"
29
+ },
30
+ "feeds": {
31
+ "title": "Feeds",
32
+ "follow": "Folgen",
33
+ "subscribe": "Einen Feed abonnieren",
34
+ "unfollow": "Entfolgen",
35
+ "empty": "Keine Feeds in diesem Kanal abonniert",
36
+ "url": "Feed-URL",
37
+ "urlPlaceholder": "https://beispiel.de/feed.xml",
38
+ "edit": "Feed bearbeiten",
39
+ "rediscover": "Feed neu entdecken",
40
+ "refresh": "Jetzt aktualisieren",
41
+ "status": {
42
+ "active": "Aktiv",
43
+ "error": "Fehler",
44
+ "stale": "Veraltet"
45
+ }
46
+ },
47
+ "item": {
48
+ "reply": "Antworten",
49
+ "like": "Gefällt mir",
50
+ "repost": "Teilen",
51
+ "bookmark": "Lesezeichen",
52
+ "viewOriginal": "Original anzeigen"
53
+ },
54
+ "compose": {
55
+ "title": "Verfassen",
56
+ "content": "Was beschäftigt Sie?",
57
+ "comment": "Kommentar hinzufügen (optional)",
58
+ "commentHint": "Ihr Kommentar wird bei der Syndizierung mit einbezogen",
59
+ "syndicateTo": "Syndizieren an",
60
+ "syndicateHint": "Wählen Sie aus, wo dies quergepostet werden soll",
61
+ "submit": "Veröffentlichen",
62
+ "cancel": "Abbrechen",
63
+ "replyTo": "Antworten an",
64
+ "likeOf": "Gefällt mir",
65
+ "repostOf": "Teilen",
66
+ "bookmarkOf": "Lesezeichen setzen"
67
+ },
68
+ "settings": {
69
+ "title": "{{channel}}-Einstellungen",
70
+ "excludeTypes": "Interaktionstypen ausschließen",
71
+ "excludeTypesHelp": "Wählen Sie Beitragstypen aus, die in diesem Kanal ausgeblendet werden sollen",
72
+ "excludeRegex": "Ausschlussmuster",
73
+ "excludeRegexHelp": "Regulärer Ausdruck zum Herausfiltern übereinstimmender Inhalte",
74
+ "save": "Einstellungen speichern",
75
+ "dangerZone": "Gefahrenzone",
76
+ "deleteWarning": "Das Löschen dieses Kanals entfernt dauerhaft alle Feeds und Elemente. Diese Aktion kann nicht rückgängig gemacht werden.",
77
+ "deleteConfirm": "Sind Sie sicher, dass Sie diesen Kanal und alle seine Inhalte löschen möchten?",
78
+ "delete": "Kanal löschen",
79
+ "types": {
80
+ "like": "Gefällt mir",
81
+ "repost": "Geteilte Beiträge",
82
+ "bookmark": "Lesezeichen",
83
+ "reply": "Antworten",
84
+ "checkin": "Check-ins"
85
+ }
86
+ },
87
+ "search": {
88
+ "title": "Suchen",
89
+ "placeholder": "URL oder Suchbegriff eingeben",
90
+ "submit": "Suchen",
91
+ "noResults": "Keine Ergebnisse gefunden"
92
+ },
93
+ "preview": {
94
+ "title": "Vorschau",
95
+ "subscribe": "Diesen Feed abonnieren"
96
+ },
97
+ "error": {
98
+ "channelNotFound": "Kanal nicht gefunden",
99
+ "feedNotFound": "Feed nicht gefunden",
100
+ "invalidUrl": "Ungültige URL",
101
+ "invalidAction": "Ungültige Aktion"
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,104 @@
1
+ {
2
+ "microsub": {
3
+ "reader": {
4
+ "title": "Lector",
5
+ "empty": "No hay elementos para mostrar",
6
+ "markAllRead": "Marcar todo como leído",
7
+ "showRead": "Mostrar leídos ({{count}})",
8
+ "hideRead": "Ocultar elementos leídos",
9
+ "allRead": "¡Todo al día!",
10
+ "newer": "Más reciente",
11
+ "older": "Más antiguo"
12
+ },
13
+ "channels": {
14
+ "title": "Canales",
15
+ "name": "Nombre del canal",
16
+ "new": "Nuevo canal",
17
+ "create": "Crear canal",
18
+ "delete": "Eliminar canal",
19
+ "settings": "Configuración del canal",
20
+ "empty": "Todavía no hay canales. Creá uno para empezar.",
21
+ "notifications": "Notificaciones"
22
+ },
23
+ "timeline": {
24
+ "title": "Línea de tiempo",
25
+ "empty": "No hay elementos en este canal",
26
+ "markRead": "Marcar como leído",
27
+ "markUnread": "Marcar como no leído",
28
+ "remove": "Eliminar"
29
+ },
30
+ "feeds": {
31
+ "title": "Feeds",
32
+ "follow": "Seguir",
33
+ "subscribe": "Suscribirse a un feed",
34
+ "unfollow": "Dejar de seguir",
35
+ "empty": "No se sigue ningún feed en este canal",
36
+ "url": "URL del feed",
37
+ "urlPlaceholder": "https://ejemplo.com/feed.xml",
38
+ "edit": "Editar feed",
39
+ "rediscover": "Redescubrir feed",
40
+ "refresh": "Actualizar ahora",
41
+ "status": {
42
+ "active": "Activo",
43
+ "error": "Error",
44
+ "stale": "Obsoleto"
45
+ }
46
+ },
47
+ "item": {
48
+ "reply": "Responder",
49
+ "like": "Me gusta",
50
+ "repost": "Repostear",
51
+ "bookmark": "Marcador",
52
+ "viewOriginal": "Ver original"
53
+ },
54
+ "compose": {
55
+ "title": "Redactar",
56
+ "content": "¿Qué estás pensando?",
57
+ "comment": "Agregar un comentario (opcional)",
58
+ "commentHint": "Tu comentario se incluirá cuando esto se sindique",
59
+ "syndicateTo": "Sindicar a",
60
+ "syndicateHint": "Seleccioná dónde publicar esto",
61
+ "submit": "Publicar",
62
+ "cancel": "Cancelar",
63
+ "replyTo": "Respondiendo a",
64
+ "likeOf": "Me gusta",
65
+ "repostOf": "Reposteando",
66
+ "bookmarkOf": "Marcando"
67
+ },
68
+ "settings": {
69
+ "title": "Configuración de {{channel}}",
70
+ "excludeTypes": "Excluir tipos de interacción",
71
+ "excludeTypesHelp": "Seleccioná los tipos de publicaciones que querés ocultar de este canal",
72
+ "excludeRegex": "Patrón de exclusión",
73
+ "excludeRegexHelp": "Expresión regular para filtrar contenido coincidente",
74
+ "save": "Guardar configuración",
75
+ "dangerZone": "Zona de peligro",
76
+ "deleteWarning": "Eliminar este canal eliminará permanentemente todos los feeds y elementos. Esta acción no se puede deshacer.",
77
+ "deleteConfirm": "¿Estás seguro de que querés eliminar este canal y todo su contenido?",
78
+ "delete": "Eliminar canal",
79
+ "types": {
80
+ "like": "Me gusta",
81
+ "repost": "Reposteos",
82
+ "bookmark": "Marcadores",
83
+ "reply": "Respuestas",
84
+ "checkin": "Registros"
85
+ }
86
+ },
87
+ "search": {
88
+ "title": "Buscar",
89
+ "placeholder": "Ingresá URL o término de búsqueda",
90
+ "submit": "Buscar",
91
+ "noResults": "No se encontraron resultados"
92
+ },
93
+ "preview": {
94
+ "title": "Vista previa",
95
+ "subscribe": "Suscribirse a este feed"
96
+ },
97
+ "error": {
98
+ "channelNotFound": "Canal no encontrado",
99
+ "feedNotFound": "Feed no encontrado",
100
+ "invalidUrl": "URL no válida",
101
+ "invalidAction": "Acción no válida"
102
+ }
103
+ }
104
+ }