@rmdes/indiekit-endpoint-blogroll 1.0.16 → 1.0.18
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 +241 -0
- package/lib/storage/blogs.js +7 -7
- package/lib/storage/items.js +1 -1
- package/lib/storage/sources.js +4 -4
- package/lib/sync/feed.js +8 -4
- package/lib/sync/microsub.js +4 -4
- package/lib/sync/scheduler.js +1 -1
- package/locales/de.json +133 -0
- package/locales/es-419.json +133 -0
- package/locales/es.json +133 -0
- package/locales/fr.json +133 -0
- package/locales/hi.json +133 -0
- package/locales/id.json +133 -0
- package/locales/it.json +133 -0
- package/locales/nl.json +133 -0
- package/locales/pl.json +133 -0
- package/locales/pt-BR.json +133 -0
- package/locales/pt.json +133 -0
- package/locales/sr.json +133 -0
- package/locales/sv.json +133 -0
- package/locales/zh-Hans-CN.json +133 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Blogroll Endpoint for Indiekit
|
|
2
|
+
|
|
3
|
+
An Indiekit plugin that provides a comprehensive blogroll management system with feed aggregation, admin UI, and public API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Multiple Source Types:** Import blogs from OPML files/URLs, Microsub subscriptions, or add manually
|
|
8
|
+
- **Background Feed Fetching:** Automatically syncs blogs and caches recent items
|
|
9
|
+
- **Microsub Integration:** Mirror your Microsub subscriptions as a blogroll (zero duplication)
|
|
10
|
+
- **Admin UI:** Manage sources, blogs, and view recent activity
|
|
11
|
+
- **Public JSON API:** Read-only endpoints for frontend integration
|
|
12
|
+
- **OPML Export:** Export your blogroll as OPML (all or by category)
|
|
13
|
+
- **Feed Discovery:** Auto-discover feeds from website URLs
|
|
14
|
+
- **Item Retention:** Automatic cleanup of old items (encourages fresh content discovery)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @rmdes/indiekit-endpoint-blogroll
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Add to your `indiekit.config.js`:
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
import BlogrollEndpoint from "@rmdes/indiekit-endpoint-blogroll";
|
|
28
|
+
|
|
29
|
+
export default {
|
|
30
|
+
plugins: [
|
|
31
|
+
new BlogrollEndpoint({
|
|
32
|
+
mountPath: "/blogrollapi", // Admin UI and API base path
|
|
33
|
+
syncInterval: 3600000, // 1 hour (in milliseconds)
|
|
34
|
+
maxItemsPerBlog: 50, // Items to fetch per blog
|
|
35
|
+
maxItemAge: 7, // Days - older items auto-deleted
|
|
36
|
+
fetchTimeout: 15000 // 15 seconds per feed fetch
|
|
37
|
+
})
|
|
38
|
+
]
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- **Indiekit:** `>=1.0.0-beta.25`
|
|
45
|
+
- **MongoDB:** Required for data storage
|
|
46
|
+
- **Optional:** `@rmdes/indiekit-endpoint-microsub` for Microsub integration
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Admin UI
|
|
51
|
+
|
|
52
|
+
Navigate to `/blogrollapi` in your Indiekit instance to access:
|
|
53
|
+
|
|
54
|
+
- **Dashboard:** View sync status, blog counts, recent activity
|
|
55
|
+
- **Sources:** Manage OPML and Microsub sources
|
|
56
|
+
- **Blogs:** Add/edit/delete individual blogs, refresh feeds
|
|
57
|
+
- **Manual Sync:** Trigger immediate sync or clear and resync
|
|
58
|
+
|
|
59
|
+
### Source Types
|
|
60
|
+
|
|
61
|
+
1. **OPML URL:** Point to a public OPML file (e.g., your feed reader's export)
|
|
62
|
+
2. **OPML File:** Paste OPML XML directly into the form
|
|
63
|
+
3. **Microsub:** Import subscriptions from your Microsub channels
|
|
64
|
+
4. **Manual:** Add individual blog feeds one at a time
|
|
65
|
+
|
|
66
|
+
### Public API
|
|
67
|
+
|
|
68
|
+
All API endpoints return JSON (except OPML export which returns XML).
|
|
69
|
+
|
|
70
|
+
**List Blogs**
|
|
71
|
+
```
|
|
72
|
+
GET /blogrollapi/api/blogs?category=Tech&limit=100&offset=0
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Get Blog with Recent Items**
|
|
76
|
+
```
|
|
77
|
+
GET /blogrollapi/api/blogs/:id
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**List Items Across All Blogs**
|
|
81
|
+
```
|
|
82
|
+
GET /blogrollapi/api/items?blog=<id>&category=Tech&limit=50&offset=0
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**List Categories**
|
|
86
|
+
```
|
|
87
|
+
GET /blogrollapi/api/categories
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Sync Status**
|
|
91
|
+
```
|
|
92
|
+
GET /blogrollapi/api/status
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Export OPML**
|
|
96
|
+
```
|
|
97
|
+
GET /blogrollapi/api/opml (all blogs)
|
|
98
|
+
GET /blogrollapi/api/opml/:category (specific category)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Example Response
|
|
102
|
+
|
|
103
|
+
**GET /blogrollapi/api/blogs**
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"items": [
|
|
107
|
+
{
|
|
108
|
+
"id": "507f1f77bcf86cd799439011",
|
|
109
|
+
"title": "Example Blog",
|
|
110
|
+
"description": "A great blog about tech",
|
|
111
|
+
"feedUrl": "https://example.com/feed",
|
|
112
|
+
"siteUrl": "https://example.com",
|
|
113
|
+
"feedType": "rss",
|
|
114
|
+
"category": "Tech",
|
|
115
|
+
"tags": ["programming", "web"],
|
|
116
|
+
"photo": "https://example.com/icon.png",
|
|
117
|
+
"status": "active",
|
|
118
|
+
"itemCount": 25,
|
|
119
|
+
"pinned": false,
|
|
120
|
+
"lastFetchAt": "2026-02-13T10:30:00.000Z"
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"total": 42,
|
|
124
|
+
"hasMore": true
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**GET /blogrollapi/api/items**
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"items": [
|
|
132
|
+
{
|
|
133
|
+
"id": "507f1f77bcf86cd799439011",
|
|
134
|
+
"url": "https://example.com/post/hello",
|
|
135
|
+
"title": "Hello World",
|
|
136
|
+
"summary": "My first blog post...",
|
|
137
|
+
"published": "2026-02-13T10:00:00.000Z",
|
|
138
|
+
"isFuture": false,
|
|
139
|
+
"author": { "name": "Jane Doe" },
|
|
140
|
+
"photo": ["https://example.com/image.jpg"],
|
|
141
|
+
"categories": ["announcement"],
|
|
142
|
+
"blog": {
|
|
143
|
+
"id": "507f1f77bcf86cd799439011",
|
|
144
|
+
"title": "Example Blog",
|
|
145
|
+
"siteUrl": "https://example.com",
|
|
146
|
+
"category": "Tech",
|
|
147
|
+
"photo": "https://example.com/icon.png"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
],
|
|
151
|
+
"hasMore": false
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Microsub Integration
|
|
156
|
+
|
|
157
|
+
If you have `@rmdes/indiekit-endpoint-microsub` installed, the blogroll can mirror your subscriptions:
|
|
158
|
+
|
|
159
|
+
1. Create a Microsub source in the admin UI
|
|
160
|
+
2. Select specific channels or sync all channels
|
|
161
|
+
3. Add a category prefix (optional) to distinguish Microsub blogs
|
|
162
|
+
4. Blogs and items are referenced, not duplicated
|
|
163
|
+
|
|
164
|
+
**Benefits:**
|
|
165
|
+
- Zero data duplication - items are served directly from Microsub
|
|
166
|
+
- Automatic orphan cleanup when feeds are unsubscribed
|
|
167
|
+
- Webhook support for real-time updates
|
|
168
|
+
|
|
169
|
+
## Background Sync
|
|
170
|
+
|
|
171
|
+
The plugin automatically syncs in the background:
|
|
172
|
+
|
|
173
|
+
1. **Initial Sync:** Runs 15 seconds after server startup
|
|
174
|
+
2. **Periodic Sync:** Runs every `syncInterval` milliseconds (default 1 hour)
|
|
175
|
+
3. **What it Does:**
|
|
176
|
+
- Syncs enabled sources (OPML/Microsub)
|
|
177
|
+
- Fetches new items from active blogs
|
|
178
|
+
- Deletes items older than `maxItemAge` days
|
|
179
|
+
- Updates sync statistics
|
|
180
|
+
|
|
181
|
+
**Manual Sync:**
|
|
182
|
+
- Trigger from the dashboard
|
|
183
|
+
- Use `POST /blogrollapi/sync` (protected endpoint)
|
|
184
|
+
- Use `POST /blogrollapi/clear-resync` to clear and resync all
|
|
185
|
+
|
|
186
|
+
## Feed Discovery
|
|
187
|
+
|
|
188
|
+
The plugin includes auto-discovery for finding feeds from website URLs:
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
// In the admin UI, when adding a blog, paste a website URL
|
|
192
|
+
// The plugin will:
|
|
193
|
+
// 1. Check <link rel="alternate"> tags in HTML
|
|
194
|
+
// 2. Try common feed paths (/feed, /rss, /atom.xml, etc.)
|
|
195
|
+
// 3. Suggest discovered feeds
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Item Retention
|
|
199
|
+
|
|
200
|
+
By default, items older than 7 days are automatically deleted during sync. This encourages discovery of fresh content rather than archiving everything.
|
|
201
|
+
|
|
202
|
+
**To Change Retention:**
|
|
203
|
+
```javascript
|
|
204
|
+
new BlogrollEndpoint({
|
|
205
|
+
maxItemAge: 30 // Keep items for 30 days instead
|
|
206
|
+
})
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Blog Status
|
|
210
|
+
|
|
211
|
+
- **active:** Blog is working, fetching items normally
|
|
212
|
+
- **error:** Last fetch failed (see `lastError` for details)
|
|
213
|
+
- **deleted:** Soft-deleted, won't be recreated by sync
|
|
214
|
+
|
|
215
|
+
## Navigation
|
|
216
|
+
|
|
217
|
+
The plugin adds itself to Indiekit's navigation:
|
|
218
|
+
|
|
219
|
+
- **Menu Item:** "Blogroll" (requires database)
|
|
220
|
+
- **Shortcut:** Bookmark icon in admin dashboard
|
|
221
|
+
|
|
222
|
+
## Security
|
|
223
|
+
|
|
224
|
+
- **Protected Routes:** Admin UI and management endpoints require authentication
|
|
225
|
+
- **Public Routes:** Read-only API endpoints are publicly accessible
|
|
226
|
+
- **XSS Prevention:** Feed content is sanitized with `sanitize-html`
|
|
227
|
+
- **Feed Discovery:** Protected to prevent abuse (requires authentication)
|
|
228
|
+
|
|
229
|
+
## Supported Feed Formats
|
|
230
|
+
|
|
231
|
+
- RSS 2.0
|
|
232
|
+
- Atom 1.0
|
|
233
|
+
- JSON Feed 1.0
|
|
234
|
+
|
|
235
|
+
## Contributing
|
|
236
|
+
|
|
237
|
+
Report issues at: https://github.com/rmdes/indiekit-endpoint-blogroll/issues
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT
|
package/lib/storage/blogs.js
CHANGED
|
@@ -86,7 +86,7 @@ export async function getBlogByFeedUrl(application, feedUrl) {
|
|
|
86
86
|
*/
|
|
87
87
|
export async function createBlog(application, data) {
|
|
88
88
|
const collection = getCollection(application);
|
|
89
|
-
const now = new Date();
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
90
|
|
|
91
91
|
const blog = {
|
|
92
92
|
sourceId: data.sourceId ? new ObjectId(data.sourceId) : null,
|
|
@@ -127,7 +127,7 @@ export async function updateBlog(application, id, data) {
|
|
|
127
127
|
|
|
128
128
|
const update = {
|
|
129
129
|
...data,
|
|
130
|
-
updatedAt: new Date(),
|
|
130
|
+
updatedAt: new Date().toISOString(),
|
|
131
131
|
};
|
|
132
132
|
|
|
133
133
|
// Remove fields that shouldn't be updated directly
|
|
@@ -162,8 +162,8 @@ export async function deleteBlog(application, id) {
|
|
|
162
162
|
$set: {
|
|
163
163
|
status: "deleted",
|
|
164
164
|
hidden: true,
|
|
165
|
-
deletedAt: new Date(),
|
|
166
|
-
updatedAt: new Date(),
|
|
165
|
+
deletedAt: new Date().toISOString(),
|
|
166
|
+
updatedAt: new Date().toISOString(),
|
|
167
167
|
},
|
|
168
168
|
}
|
|
169
169
|
);
|
|
@@ -181,12 +181,12 @@ export async function updateBlogStatus(application, id, status) {
|
|
|
181
181
|
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
|
182
182
|
|
|
183
183
|
const update = {
|
|
184
|
-
updatedAt: new Date(),
|
|
184
|
+
updatedAt: new Date().toISOString(),
|
|
185
185
|
};
|
|
186
186
|
|
|
187
187
|
if (status.success) {
|
|
188
188
|
update.status = "active";
|
|
189
|
-
update.lastFetchAt = new Date();
|
|
189
|
+
update.lastFetchAt = new Date().toISOString();
|
|
190
190
|
update.lastError = null;
|
|
191
191
|
if (status.itemCount !== undefined) {
|
|
192
192
|
update.itemCount = status.itemCount;
|
|
@@ -246,7 +246,7 @@ export async function getCategories(application) {
|
|
|
246
246
|
*/
|
|
247
247
|
export async function upsertBlog(application, data) {
|
|
248
248
|
const collection = getCollection(application);
|
|
249
|
-
const now = new Date();
|
|
249
|
+
const now = new Date().toISOString();
|
|
250
250
|
|
|
251
251
|
// Skip if a blog with this feedUrl was soft-deleted
|
|
252
252
|
const deleted = await collection.findOne({
|
package/lib/storage/items.js
CHANGED
|
@@ -206,7 +206,7 @@ export async function countItems(application, options = {}) {
|
|
|
206
206
|
*/
|
|
207
207
|
export async function upsertItem(application, data) {
|
|
208
208
|
const collection = getCollection(application);
|
|
209
|
-
const now = new Date();
|
|
209
|
+
const now = new Date().toISOString();
|
|
210
210
|
|
|
211
211
|
const result = await collection.updateOne(
|
|
212
212
|
{ blogId: new ObjectId(data.blogId), uid: data.uid },
|
package/lib/storage/sources.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function getSource(application, id) {
|
|
|
45
45
|
*/
|
|
46
46
|
export async function createSource(application, data) {
|
|
47
47
|
const collection = getCollection(application);
|
|
48
|
-
const now = new Date();
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
49
|
|
|
50
50
|
const source = {
|
|
51
51
|
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub"
|
|
@@ -80,7 +80,7 @@ export async function updateSource(application, id, data) {
|
|
|
80
80
|
|
|
81
81
|
const update = {
|
|
82
82
|
...data,
|
|
83
|
-
updatedAt: new Date(),
|
|
83
|
+
updatedAt: new Date().toISOString(),
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
// Remove fields that shouldn't be updated directly
|
|
@@ -135,11 +135,11 @@ export async function updateSourceSyncStatus(application, id, status) {
|
|
|
135
135
|
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
|
136
136
|
|
|
137
137
|
const update = {
|
|
138
|
-
updatedAt: new Date(),
|
|
138
|
+
updatedAt: new Date().toISOString(),
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
if (status.success) {
|
|
142
|
-
update.lastSyncAt = new Date();
|
|
142
|
+
update.lastSyncAt = new Date().toISOString();
|
|
143
143
|
update.lastSyncError = null;
|
|
144
144
|
} else {
|
|
145
145
|
update.lastSyncError = status.error;
|
package/lib/sync/feed.js
CHANGED
|
@@ -142,8 +142,8 @@ function parseJsonFeed(content, feedUrl, maxItems) {
|
|
|
142
142
|
text: item.content_text,
|
|
143
143
|
},
|
|
144
144
|
summary: decodeEntities(item.summary) || truncateText(item.content_text, 300),
|
|
145
|
-
published: item.date_published ? new Date(item.date_published) : new Date(),
|
|
146
|
-
updated: item.date_modified ? new Date(item.date_modified) : undefined,
|
|
145
|
+
published: item.date_published ? new Date(item.date_published).toISOString() : new Date().toISOString(),
|
|
146
|
+
updated: item.date_modified ? new Date(item.date_modified).toISOString() : undefined,
|
|
147
147
|
author: item.author || (item.authors?.[0]),
|
|
148
148
|
photo: item.image ? [item.image] : undefined,
|
|
149
149
|
categories: item.tags || [],
|
|
@@ -168,6 +168,10 @@ function parseJsonFeed(content, feedUrl, maxItems) {
|
|
|
168
168
|
function normalizeItem(item, feedUrl) {
|
|
169
169
|
const description = item.description || item.summary || "";
|
|
170
170
|
|
|
171
|
+
// Convert dates to ISO strings - feedparser returns Date objects
|
|
172
|
+
const published = item.pubdate || item.date;
|
|
173
|
+
const updated = item.date;
|
|
174
|
+
|
|
171
175
|
return {
|
|
172
176
|
uid: generateUid(feedUrl, item.guid || item.link),
|
|
173
177
|
url: item.link || item.origlink,
|
|
@@ -177,8 +181,8 @@ function normalizeItem(item, feedUrl) {
|
|
|
177
181
|
text: stripHtml(description),
|
|
178
182
|
},
|
|
179
183
|
summary: truncateText(stripHtml(item.summary || description), 300),
|
|
180
|
-
published:
|
|
181
|
-
updated:
|
|
184
|
+
published: published ? (published instanceof Date ? published.toISOString() : new Date(published).toISOString()) : new Date().toISOString(),
|
|
185
|
+
updated: updated ? (updated instanceof Date ? updated.toISOString() : new Date(updated).toISOString()) : undefined,
|
|
182
186
|
author: item.author ? { name: item.author } : undefined,
|
|
183
187
|
photo: extractPhotos(item),
|
|
184
188
|
categories: item.categories || [],
|
package/lib/sync/microsub.js
CHANGED
|
@@ -100,8 +100,8 @@ export async function syncMicrosubSource(application, source) {
|
|
|
100
100
|
$set: {
|
|
101
101
|
status: "deleted",
|
|
102
102
|
hidden: true,
|
|
103
|
-
deletedAt: new Date(),
|
|
104
|
-
updatedAt: new Date(),
|
|
103
|
+
deletedAt: new Date().toISOString(),
|
|
104
|
+
updatedAt: new Date().toISOString(),
|
|
105
105
|
},
|
|
106
106
|
}
|
|
107
107
|
);
|
|
@@ -227,8 +227,8 @@ export async function handleMicrosubWebhook(application, data) {
|
|
|
227
227
|
{
|
|
228
228
|
$set: {
|
|
229
229
|
status: "inactive",
|
|
230
|
-
unsubscribedAt: new Date(),
|
|
231
|
-
updatedAt: new Date(),
|
|
230
|
+
unsubscribedAt: new Date().toISOString(),
|
|
231
|
+
updatedAt: new Date().toISOString(),
|
|
232
232
|
},
|
|
233
233
|
}
|
|
234
234
|
);
|
package/lib/sync/scheduler.js
CHANGED
package/locales/de.json
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
{
|
|
2
|
+
"blogroll": {
|
|
3
|
+
"title": "Blogroll",
|
|
4
|
+
"description": "Verwalten Sie Ihre Blogroll-Quellen und Blogs",
|
|
5
|
+
"enabled": "Aktiviert",
|
|
6
|
+
"disabled": "Deaktiviert",
|
|
7
|
+
"edit": "Bearbeiten",
|
|
8
|
+
"sync": "Synchronisieren",
|
|
9
|
+
"refresh": "Aktualisieren",
|
|
10
|
+
"cancel": "Abbrechen",
|
|
11
|
+
"never": "Nie",
|
|
12
|
+
|
|
13
|
+
"stats": {
|
|
14
|
+
"title": "Übersicht",
|
|
15
|
+
"sources": "Quellen",
|
|
16
|
+
"blogs": "Blogs",
|
|
17
|
+
"items": "Einträge",
|
|
18
|
+
"errors": "Fehler",
|
|
19
|
+
"lastSync": "Letzte Synchronisation"
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
"actions": {
|
|
23
|
+
"title": "Aktionen",
|
|
24
|
+
"syncNow": "Jetzt synchronisieren",
|
|
25
|
+
"clearResync": "Löschen & Neu synchronisieren",
|
|
26
|
+
"clearConfirm": "Dadurch werden alle zwischengespeicherten Einträge gelöscht und alles neu abgerufen. Fortfahren?"
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
"errors": {
|
|
30
|
+
"title": "Blogs mit Fehlern",
|
|
31
|
+
"seeAll": "Alle %{count} Blogs mit Fehlern anzeigen"
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
"sources": {
|
|
35
|
+
"title": "OPML-Synchronisation",
|
|
36
|
+
"manage": "OPML-Synchronisation",
|
|
37
|
+
"add": "OPML-Quelle hinzufügen",
|
|
38
|
+
"new": "Neue OPML-Quelle",
|
|
39
|
+
"edit": "OPML-Quelle bearbeiten",
|
|
40
|
+
"create": "Erstellen",
|
|
41
|
+
"save": "Speichern",
|
|
42
|
+
"empty": "Keine OPML-Quellen konfiguriert. Verwenden Sie diese Funktion, um Blogs aus FreshRSS oder anderen Feed-Readern zu importieren.",
|
|
43
|
+
"recent": "OPML-Quellen",
|
|
44
|
+
"interval": "Alle %{minutes} Min.",
|
|
45
|
+
"lastSync": "Zuletzt synchronisiert",
|
|
46
|
+
"deleteConfirm": "Diese OPML-Quelle löschen? Importierte Blogs bleiben erhalten.",
|
|
47
|
+
"created": "OPML-Quelle erfolgreich erstellt.",
|
|
48
|
+
"created_synced": "OPML-Quelle erfolgreich erstellt und synchronisiert.",
|
|
49
|
+
"created_sync_failed": "OPML-Quelle erstellt, aber Synchronisation fehlgeschlagen: %{error}",
|
|
50
|
+
"updated": "OPML-Quelle erfolgreich aktualisiert.",
|
|
51
|
+
"deleted": "OPML-Quelle erfolgreich gelöscht.",
|
|
52
|
+
"synced": "Erfolgreich synchronisiert. Hinzugefügt: %{added}, Aktualisiert: %{updated}",
|
|
53
|
+
"form": {
|
|
54
|
+
"name": "Name",
|
|
55
|
+
"type": "Import-Typ",
|
|
56
|
+
"typeHint": "URL synchronisiert regelmäßig, Datei ist ein einmaliger Import",
|
|
57
|
+
"url": "OPML-URL",
|
|
58
|
+
"urlHint": "URL zu Ihrer OPML-Datei (z.B. FreshRSS-Export-URL)",
|
|
59
|
+
"opmlContent": "OPML-Inhalt",
|
|
60
|
+
"opmlContentHint": "Fügen Sie hier den vollständigen OPML-XML-Inhalt ein",
|
|
61
|
+
"syncInterval": "Synchronisationsintervall",
|
|
62
|
+
"enabled": "Automatische Synchronisation aktivieren"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
"blogs": {
|
|
67
|
+
"title": "Blogs",
|
|
68
|
+
"manage": "Blogs verwalten",
|
|
69
|
+
"add": "Blog hinzufügen",
|
|
70
|
+
"new": "Neuer Blog",
|
|
71
|
+
"edit": "Blog bearbeiten",
|
|
72
|
+
"create": "Blog hinzufügen",
|
|
73
|
+
"save": "Blog speichern",
|
|
74
|
+
"empty": "Noch keine Blogs. Fügen Sie einen hinzu oder importieren Sie aus einer OPML-Quelle.",
|
|
75
|
+
"recent": "Aktuelle Blogs",
|
|
76
|
+
"pinned": "Angeheftet",
|
|
77
|
+
"hidden": "Versteckt",
|
|
78
|
+
"noItems": "Noch keine Einträge abgerufen.",
|
|
79
|
+
"recentItems": "Aktuelle Einträge",
|
|
80
|
+
"allCategories": "Alle Kategorien",
|
|
81
|
+
"allStatuses": "Alle Status",
|
|
82
|
+
"statusActive": "Aktiv",
|
|
83
|
+
"statusError": "Fehler",
|
|
84
|
+
"statusPending": "Ausstehend",
|
|
85
|
+
"clearFilters": "Filter löschen",
|
|
86
|
+
"deleteConfirm": "Diesen Blog und alle zwischengespeicherten Einträge löschen?",
|
|
87
|
+
"created": "Blog erfolgreich hinzugefügt.",
|
|
88
|
+
"created_synced": "Blog hinzugefügt und synchronisiert. %{items} Einträge abgerufen.",
|
|
89
|
+
"created_sync_failed": "Blog hinzugefügt, aber erster Abruf fehlgeschlagen: %{error}",
|
|
90
|
+
"updated": "Blog erfolgreich aktualisiert.",
|
|
91
|
+
"deleted": "Blog erfolgreich gelöscht.",
|
|
92
|
+
"refreshed": "Blog aktualisiert. %{items} neue Einträge hinzugefügt.",
|
|
93
|
+
"form": {
|
|
94
|
+
"discoverUrl": "Website-URL",
|
|
95
|
+
"discover": "Feed entdecken",
|
|
96
|
+
"discoverHint": "Geben Sie eine Website-URL ein, um automatisch RSS/Atom-Feeds zu finden",
|
|
97
|
+
"discoverNoUrl": "Bitte geben Sie eine Website-URL ein",
|
|
98
|
+
"discovering": "Suche läuft...",
|
|
99
|
+
"discoveringHint": "Suche nach RSS/Atom-Feeds...",
|
|
100
|
+
"discoverFailed": "Feeds konnten nicht gefunden werden",
|
|
101
|
+
"discoverNoFeeds": "Keine Feeds auf dieser Website gefunden",
|
|
102
|
+
"discoverFoundOne": "Feed gefunden:",
|
|
103
|
+
"discoverFoundMultiple": "Mehrere Feeds gefunden. Klicken Sie auf einen, um ihn auszuwählen:",
|
|
104
|
+
"discoverSelected": "Ausgewählter Feed:",
|
|
105
|
+
"feedUrl": "Feed-URL",
|
|
106
|
+
"feedUrlHint": "RSS-, Atom- oder JSON-Feed-URL",
|
|
107
|
+
"title": "Titel",
|
|
108
|
+
"titlePlaceholder": "Automatisch aus Feed erkannt",
|
|
109
|
+
"titleHint": "Leer lassen, um den Feed-Titel zu verwenden",
|
|
110
|
+
"siteUrl": "Website-URL",
|
|
111
|
+
"siteUrlHint": "Link zur Startseite des Blogs (optional)",
|
|
112
|
+
"category": "Kategorie",
|
|
113
|
+
"categoryHint": "Gruppieren Sie Blogs nach Kategorie zum Filtern und OPML-Export",
|
|
114
|
+
"tags": "Schlagwörter",
|
|
115
|
+
"tagsHint": "Kommagetrennte Schlagwörter für zusätzliche Organisation",
|
|
116
|
+
"notes": "Notizen",
|
|
117
|
+
"notesPlaceholder": "Warum Sie diesem Blog folgen...",
|
|
118
|
+
"notesHint": "Persönliche Notizen (nicht öffentlich sichtbar)",
|
|
119
|
+
"pinned": "Diesen Blog anheften (oben in Listen anzeigen)",
|
|
120
|
+
"hidden": "Vor öffentlicher API verbergen (nur für Sie sichtbar)"
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
"api": {
|
|
125
|
+
"title": "API-Endpunkte",
|
|
126
|
+
"blogs": "Alle Blogs mit Metadaten auflisten",
|
|
127
|
+
"items": "Aktuelle Einträge von allen Blogs auflisten",
|
|
128
|
+
"categories": "Alle Kategorien auflisten",
|
|
129
|
+
"opml": "Als OPML exportieren",
|
|
130
|
+
"status": "Synchronisationsstatus und Statistiken"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
{
|
|
2
|
+
"blogroll": {
|
|
3
|
+
"title": "Blogroll",
|
|
4
|
+
"description": "Administra tus fuentes de blogroll y blogs",
|
|
5
|
+
"enabled": "Habilitado",
|
|
6
|
+
"disabled": "Deshabilitado",
|
|
7
|
+
"edit": "Editar",
|
|
8
|
+
"sync": "Sincronizar",
|
|
9
|
+
"refresh": "Actualizar",
|
|
10
|
+
"cancel": "Cancelar",
|
|
11
|
+
"never": "Nunca",
|
|
12
|
+
|
|
13
|
+
"stats": {
|
|
14
|
+
"title": "Resumen",
|
|
15
|
+
"sources": "Fuentes",
|
|
16
|
+
"blogs": "Blogs",
|
|
17
|
+
"items": "Entradas",
|
|
18
|
+
"errors": "Errores",
|
|
19
|
+
"lastSync": "Última sincronización"
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
"actions": {
|
|
23
|
+
"title": "Acciones",
|
|
24
|
+
"syncNow": "Sincronizar todo ahora",
|
|
25
|
+
"clearResync": "Limpiar y resincronizar",
|
|
26
|
+
"clearConfirm": "Esto eliminará todas las entradas almacenadas en caché y volverá a descargar todo. ¿Continuar?"
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
"errors": {
|
|
30
|
+
"title": "Blogs con errores",
|
|
31
|
+
"seeAll": "Ver los %{count} blogs con errores"
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
"sources": {
|
|
35
|
+
"title": "Sincronización OPML",
|
|
36
|
+
"manage": "Sincronización OPML",
|
|
37
|
+
"add": "Agregar fuente OPML",
|
|
38
|
+
"new": "Nueva fuente OPML",
|
|
39
|
+
"edit": "Editar fuente OPML",
|
|
40
|
+
"create": "Crear",
|
|
41
|
+
"save": "Guardar",
|
|
42
|
+
"empty": "No hay fuentes OPML configuradas. Usa esto para importar blogs de forma masiva desde FreshRSS u otros lectores de feeds.",
|
|
43
|
+
"recent": "Fuentes OPML",
|
|
44
|
+
"interval": "Cada %{minutes} min",
|
|
45
|
+
"lastSync": "Última sincronización",
|
|
46
|
+
"deleteConfirm": "¿Eliminar esta fuente OPML? Los blogs importados se conservarán.",
|
|
47
|
+
"created": "Fuente OPML creada exitosamente.",
|
|
48
|
+
"created_synced": "Fuente OPML creada y sincronizada exitosamente.",
|
|
49
|
+
"created_sync_failed": "Fuente OPML creada, pero la sincronización falló: %{error}",
|
|
50
|
+
"updated": "Fuente OPML actualizada exitosamente.",
|
|
51
|
+
"deleted": "Fuente OPML eliminada exitosamente.",
|
|
52
|
+
"synced": "Sincronización exitosa. Agregados: %{added}, Actualizados: %{updated}",
|
|
53
|
+
"form": {
|
|
54
|
+
"name": "Nombre",
|
|
55
|
+
"type": "Tipo de importación",
|
|
56
|
+
"typeHint": "La URL sincroniza periódicamente, el archivo es una importación única",
|
|
57
|
+
"url": "URL OPML",
|
|
58
|
+
"urlHint": "URL de tu archivo OPML (ej., URL de exportación de FreshRSS)",
|
|
59
|
+
"opmlContent": "Contenido OPML",
|
|
60
|
+
"opmlContentHint": "Pega aquí el contenido XML OPML completo",
|
|
61
|
+
"syncInterval": "Intervalo de sincronización",
|
|
62
|
+
"enabled": "Habilitar sincronización automática"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
"blogs": {
|
|
67
|
+
"title": "Blogs",
|
|
68
|
+
"manage": "Administrar blogs",
|
|
69
|
+
"add": "Agregar blog",
|
|
70
|
+
"new": "Nuevo blog",
|
|
71
|
+
"edit": "Editar blog",
|
|
72
|
+
"create": "Agregar blog",
|
|
73
|
+
"save": "Guardar blog",
|
|
74
|
+
"empty": "Todavía no hay blogs. Agrega uno o importa desde una fuente OPML.",
|
|
75
|
+
"recent": "Blogs recientes",
|
|
76
|
+
"pinned": "Fijado",
|
|
77
|
+
"hidden": "Oculto",
|
|
78
|
+
"noItems": "Aún no se descargaron entradas.",
|
|
79
|
+
"recentItems": "Entradas recientes",
|
|
80
|
+
"allCategories": "Todas las categorías",
|
|
81
|
+
"allStatuses": "Todos los estados",
|
|
82
|
+
"statusActive": "Activo",
|
|
83
|
+
"statusError": "Error",
|
|
84
|
+
"statusPending": "Pendiente",
|
|
85
|
+
"clearFilters": "Limpiar filtros",
|
|
86
|
+
"deleteConfirm": "¿Eliminar este blog y todas sus entradas almacenadas?",
|
|
87
|
+
"created": "Blog agregado exitosamente.",
|
|
88
|
+
"created_synced": "Blog agregado y sincronizado. Se descargaron %{items} entradas.",
|
|
89
|
+
"created_sync_failed": "Blog agregado, pero la descarga inicial falló: %{error}",
|
|
90
|
+
"updated": "Blog actualizado exitosamente.",
|
|
91
|
+
"deleted": "Blog eliminado exitosamente.",
|
|
92
|
+
"refreshed": "Blog actualizado. Se agregaron %{items} entradas nuevas.",
|
|
93
|
+
"form": {
|
|
94
|
+
"discoverUrl": "URL del sitio web",
|
|
95
|
+
"discover": "Descubrir feed",
|
|
96
|
+
"discoverHint": "Ingresa una URL de sitio web para descubrir automáticamente su feed RSS/Atom",
|
|
97
|
+
"discoverNoUrl": "Por favor ingresa una URL de sitio web",
|
|
98
|
+
"discovering": "Descubriendo...",
|
|
99
|
+
"discoveringHint": "Buscando feeds RSS/Atom...",
|
|
100
|
+
"discoverFailed": "No se pudieron descubrir feeds",
|
|
101
|
+
"discoverNoFeeds": "No se encontraron feeds en este sitio web",
|
|
102
|
+
"discoverFoundOne": "Feed encontrado:",
|
|
103
|
+
"discoverFoundMultiple": "Se encontraron varios feeds. Hace clic en uno para seleccionarlo:",
|
|
104
|
+
"discoverSelected": "Feed seleccionado:",
|
|
105
|
+
"feedUrl": "URL del feed",
|
|
106
|
+
"feedUrlHint": "URL de RSS, Atom o JSON Feed",
|
|
107
|
+
"title": "Título",
|
|
108
|
+
"titlePlaceholder": "Detectado automáticamente del feed",
|
|
109
|
+
"titleHint": "Dejar en blanco para usar el título del feed",
|
|
110
|
+
"siteUrl": "URL del sitio",
|
|
111
|
+
"siteUrlHint": "Enlace a la página principal del blog (opcional)",
|
|
112
|
+
"category": "Categoría",
|
|
113
|
+
"categoryHint": "Agrupa blogs por categoría para filtrar y exportar a OPML",
|
|
114
|
+
"tags": "Etiquetas",
|
|
115
|
+
"tagsHint": "Etiquetas separadas por comas para organización adicional",
|
|
116
|
+
"notes": "Notas",
|
|
117
|
+
"notesPlaceholder": "Por qué sigues este blog...",
|
|
118
|
+
"notesHint": "Notas personales (no se muestran públicamente)",
|
|
119
|
+
"pinned": "Fijar este blog (mostrar al inicio de las listas)",
|
|
120
|
+
"hidden": "Ocultar de la API pública (visible solo para vos)"
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
"api": {
|
|
125
|
+
"title": "Endpoints de la API",
|
|
126
|
+
"blogs": "Listar todos los blogs con metadatos",
|
|
127
|
+
"items": "Listar entradas recientes de todos los blogs",
|
|
128
|
+
"categories": "Listar todas las categorías",
|
|
129
|
+
"opml": "Exportar como OPML",
|
|
130
|
+
"status": "Estado de sincronización y estadísticas"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|