Merge upstream feat/mastodon-client-api (v3.0.0–v3.6.8) into svemagie/main

Incorporates the full Mastodon Client API compatibility layer from upstream
rmdes/indiekit-endpoint-activitypub@feat/mastodon-client-api into our fork,
which retains our custom additions (likePost, /api/ap-url, async jf2ToAS2Activity,
direct-message support, resolveAuthor, PeerTube view short-circuit, OG images).

Upstream additions:
- lib/mastodon/ — 27-file Mastodon API implementation (entities, helpers,
  middleware, routes, router, backfill-timeline)
- locales/ — 13 additional language files (es, fr, de, hi, id, it, nl, pl, pt,
  pt-BR, sr, sv, zh-Hans-CN)
- index.js — Mastodon router wiring (createMastodonRouter, setLocalIdentity,
  backfillTimeline import)
- package.json — version bump to 3.6.8, add @indiekit/endpoint-micropub peer dep
- federation-setup.js — signatureTimeWindow and allowPrivateAddress now built-in
  (previously applied only via blog repo postinstall patches)

Auto-merged cleanly; no conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-21 15:25:54 +01:00
43 changed files with 10591 additions and 1 deletions

View File

@@ -1,6 +1,8 @@
import express from "express";
import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
import { createMastodonRouter } from "./lib/mastodon/router.js";
import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
import { initRedisCache } from "./lib/redis-cache.js";
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
import {
@@ -224,6 +226,14 @@ export default class ActivityPubEndpoint {
// Skip Fedify for admin UI routes — they're handled by the
// authenticated `routes` getter, not the federation layer.
if (req.path.startsWith("/admin")) return next();
// Diagnostic: log inbox POSTs to detect federation stalls
if (req.method === "POST" && req.path.includes("inbox")) {
const ua = req.get("user-agent") || "unknown";
const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0;
console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`);
}
return self._fedifyMiddleware(req, res, next);
});
@@ -1375,6 +1385,10 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_key_freshness");
// Async inbox processing queue
Indiekit.addCollection("ap_inbox_queue");
// Mastodon Client API collections
Indiekit.addCollection("ap_oauth_apps");
Indiekit.addCollection("ap_oauth_tokens");
Indiekit.addCollection("ap_markers");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -1408,6 +1422,10 @@ export default class ActivityPubEndpoint {
ap_key_freshness: indiekitCollections.get("ap_key_freshness"),
// Async inbox processing queue
ap_inbox_queue: indiekitCollections.get("ap_inbox_queue"),
// Mastodon Client API collections
ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"),
ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
ap_markers: indiekitCollections.get("ap_markers"),
get posts() {
return indiekitCollections.get("posts");
},
@@ -1629,6 +1647,24 @@ export default class ActivityPubEndpoint {
{ processedAt: 1 },
{ expireAfterSeconds: 86_400, background: true },
);
// Mastodon Client API indexes
this._collections.ap_oauth_apps.createIndex(
{ clientId: 1 },
{ unique: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ accessToken: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ code: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_markers.createIndex(
{ userId: 1, timeline: 1 },
{ unique: true, background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
@@ -1695,6 +1731,29 @@ export default class ActivityPubEndpoint {
routesPublic: this.contentNegotiationRoutes,
});
// Set local identity for own-post detection in status serialization
setLocalIdentity(this._publicationUrl, this.options.actor?.handle || "user");
// Mastodon Client API — virtual endpoint at root
// Mastodon-compatible clients (Phanpy, Elk, etc.) expect /api/v1/*,
// /api/v2/*, /oauth/* at the domain root, not under /activitypub.
const pluginRef = this;
const mastodonRouter = createMastodonRouter({
collections: this._collections,
pluginOptions: {
handle: this.options.actor?.handle || "user",
publicationUrl: this._publicationUrl,
federation: this._federation,
followActor: (url, info) => pluginRef.followActor(url, info),
unfollowActor: (url) => pluginRef.unfollowActor(url),
},
});
Indiekit.addEndpoint({
name: "Mastodon Client API",
mountPath: "/",
routesPublic: mastodonRouter,
});
// Register syndicator (appears in post editing UI)
Indiekit.addSyndicator(this.syndicator);
@@ -1743,6 +1802,20 @@ export default class ActivityPubEndpoint {
keyRefreshHandle,
);
// Backfill ap_timeline from posts collection (idempotent, runs on every startup)
import("./lib/mastodon/backfill-timeline.js").then(({ backfillTimeline }) => {
// Delay to let MongoDB connections settle
setTimeout(() => {
backfillTimeline(this._collections).then(({ total, inserted, skipped }) => {
if (inserted > 0) {
console.log(`[Mastodon API] Timeline backfill: ${inserted} posts added (${skipped} already existed, ${total} total)`);
}
}).catch((error) => {
console.warn("[Mastodon API] Timeline backfill failed:", error.message);
});
}, 5000);
});
// Start async inbox queue processor (processes one item every 3s)
this._inboxProcessorInterval = startInboxProcessor(
this._collections,

View File

@@ -0,0 +1,311 @@
/**
* Backfill ap_timeline from the posts collection.
*
* Runs on startup (idempotent — uses upsert by uid).
* Converts Micropub JF2 posts into ap_timeline format so they
* appear in Mastodon Client API timelines and profile views.
*/
/**
* Backfill ap_timeline with published posts from the posts collection.
*
* @param {object} collections - MongoDB collections (must include posts, ap_timeline, ap_profile)
* @returns {Promise<{ total: number, inserted: number, skipped: number }>}
*/
export async function backfillTimeline(collections) {
const { posts, ap_timeline, ap_profile } = collections;
if (!posts || !ap_timeline) {
return { total: 0, inserted: 0, skipped: 0 };
}
// Get local profile for author info
const profile = await ap_profile?.findOne({});
const siteUrl = profile?.url?.replace(/\/$/, "") || "";
const author = profile
? {
name: profile.name || "",
url: profile.url || "",
photo: profile.icon || "",
handle: "",
}
: { name: "", url: "", photo: "", handle: "" };
// Fetch all published posts
const allPosts = await posts
.find({
"properties.post-status": { $ne: "draft" },
"properties.deleted": { $exists: false },
"properties.url": { $exists: true },
})
.toArray();
let inserted = 0;
let skipped = 0;
for (const post of allPosts) {
const props = post.properties;
if (!props?.url) {
skipped++;
continue;
}
const uid = props.url;
// Check if already in timeline (fast path to avoid unnecessary upserts)
const exists = await ap_timeline.findOne({ uid }, { projection: { _id: 1 } });
if (exists) {
skipped++;
continue;
}
// Build content — interaction types (bookmark, like, repost) may not have
// body content, so synthesize it from the interaction target URL
const content = buildContent(props);
const type = mapPostType(props["post-type"]);
// Extract categories + inline hashtags from content
const categories = normalizeArray(props.category);
const inlineHashtags = extractHashtags(content.text + " " + (content.html || ""));
const mergedCategories = mergeCategories(categories, inlineHashtags);
const timelineItem = {
uid,
url: uid,
type,
content: rewriteHashtagLinks(content, siteUrl),
author,
published: props.published || props.date || new Date().toISOString(),
createdAt: props.published || props.date || new Date().toISOString(),
visibility: "public",
sensitive: false,
category: mergedCategories,
photo: normalizeMediaArray(props.photo, siteUrl),
video: normalizeMediaArray(props.video, siteUrl),
audio: normalizeMediaArray(props.audio, siteUrl),
readBy: [],
};
// Optional fields
if (props.name) timelineItem.name = props.name;
if (props.summary) timelineItem.summary = props.summary;
if (props["in-reply-to"]) {
timelineItem.inReplyTo = Array.isArray(props["in-reply-to"])
? props["in-reply-to"][0]
: props["in-reply-to"];
}
try {
const result = await ap_timeline.updateOne(
{ uid },
{ $setOnInsert: timelineItem },
{ upsert: true },
);
if (result.upsertedCount > 0) {
inserted++;
} else {
skipped++;
}
} catch {
skipped++;
}
}
return { total: allPosts.length, inserted, skipped };
}
// ─── Content Building ─────────────────────────────────────────────────────────
/**
* Build content from JF2 properties, synthesizing content for interaction types.
* Bookmarks, likes, and reposts often have no body text — show the target URL.
*/
function buildContent(props) {
const raw = normalizeContent(props.content);
// If there's already content, use it
if (raw.text || raw.html) return raw;
// Synthesize content for interaction types
const bookmarkOf = props["bookmark-of"];
const likeOf = props["like-of"];
const repostOf = props["repost-of"];
const name = props.name;
if (bookmarkOf) {
const label = name || bookmarkOf;
return {
text: `Bookmarked: ${label}`,
html: `<p>Bookmarked: <a href="${escapeHtml(bookmarkOf)}">${escapeHtml(label)}</a></p>`,
};
}
if (likeOf) {
return {
text: `Liked: ${likeOf}`,
html: `<p>Liked: <a href="${escapeHtml(likeOf)}">${escapeHtml(likeOf)}</a></p>`,
};
}
if (repostOf) {
const label = name || repostOf;
return {
text: `Reposted: ${label}`,
html: `<p>Reposted: <a href="${escapeHtml(repostOf)}">${escapeHtml(label)}</a></p>`,
};
}
// Article with title but no body
if (name) {
return { text: name, html: `<p>${escapeHtml(name)}</p>` };
}
return raw;
}
/**
* Normalize content from JF2 properties to { text, html } format.
*/
function normalizeContent(content) {
if (!content) return { text: "", html: "" };
if (typeof content === "string") return { text: content, html: `<p>${content}</p>` };
if (typeof content === "object") {
return {
text: content.text || content.value || "",
html: content.html || content.text || content.value || "",
};
}
return { text: "", html: "" };
}
// ─── Hashtag Handling ─────────────────────────────────────────────────────────
/**
* Extract hashtags from text content.
* Matches #word patterns, returns lowercase tag names without the # prefix.
*/
function extractHashtags(text) {
if (!text) return [];
const matches = text.match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
if (!matches) return [];
return [...new Set(matches.map((m) => m.trim().slice(1).toLowerCase()))];
}
/**
* Merge explicit categories with inline hashtags (deduplicated, case-insensitive).
*/
function mergeCategories(categories, hashtags) {
const seen = new Set(categories.map((c) => c.toLowerCase()));
const result = [...categories];
for (const tag of hashtags) {
if (!seen.has(tag)) {
seen.add(tag);
result.push(tag);
}
}
return result;
}
/**
* Rewrite hashtag links in HTML from site-internal (/categories/tag/) to
* Mastodon-compatible format. Mastodon clients use the tag objects, not
* inline links, but having correct href helps with link following.
*/
function rewriteHashtagLinks(content, siteUrl) {
if (!content.html) return content;
// Rewrite /categories/tag/ links to /tags/tag (Mastodon convention)
let html = content.html.replace(
/href="\/categories\/([^/"]+)\/?"/g,
(_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
);
// Also rewrite absolute site category links
if (siteUrl) {
html = html.replace(
new RegExp(`href="${escapeRegex(siteUrl)}/categories/([^/"]+)/?"`, "g"),
(_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
);
}
return { ...content, html };
}
// ─── Post Type Mapping ────────────────────────────────────────────────────────
/**
* Map Micropub post-type to timeline type.
*/
function mapPostType(postType) {
switch (postType) {
case "article":
return "article";
case "photo":
case "video":
case "audio":
return "note";
case "reply":
return "note";
case "repost":
return "boost";
case "like":
case "bookmark":
return "note";
default:
return "note";
}
}
// ─── Normalization Helpers ────────────────────────────────────────────────────
/**
* Normalize a value to an array of strings.
*/
function normalizeArray(value) {
if (!value) return [];
if (Array.isArray(value)) return value.map(String);
return [String(value)];
}
/**
* Normalize media values — resolves relative URLs to absolute.
*
* @param {*} value - String, object with url, or array thereof
* @param {string} siteUrl - Base site URL for resolving relative paths
*/
function normalizeMediaArray(value, siteUrl) {
if (!value) return [];
const arr = Array.isArray(value) ? value : [value];
return arr.map((item) => {
if (typeof item === "string") return resolveUrl(item, siteUrl);
if (typeof item === "object" && item.url) {
return { ...item, url: resolveUrl(item.url, siteUrl) };
}
return null;
}).filter(Boolean);
}
/**
* Resolve a URL — if relative, prepend the site URL.
*/
function resolveUrl(url, siteUrl) {
if (!url) return url;
if (url.startsWith("http://") || url.startsWith("https://")) return url;
if (url.startsWith("/")) return `${siteUrl}${url}`;
return `${siteUrl}/${url}`;
}
/**
* Escape HTML entities.
*/
function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Escape regex special characters.
*/
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,215 @@
/**
* Account entity serializer for Mastodon Client API.
*
* Converts local profile (ap_profile) and remote actor objects
* (from timeline author, follower/following docs) into the
* Mastodon Account JSON shape that masto.js expects.
*/
import { accountId } from "../helpers/id-mapping.js";
import { sanitizeHtml, stripHtml } from "./sanitize.js";
import { getCachedAccountStats } from "../helpers/account-cache.js";
/**
* Serialize an actor as a Mastodon Account entity.
*
* Handles two shapes:
* - Local profile: { _id, name, summary, url, icon, image, actorType,
* manuallyApprovesFollowers, attachments, createdAt, ... }
* - Remote author (from timeline): { name, url, photo, handle, emojis, bot }
* - Follower/following doc: { actorUrl, name, handle, avatar, ... }
*
* @param {object} actor - Actor document (profile, author, or follower)
* @param {object} options
* @param {string} options.baseUrl - Server base URL
* @param {boolean} [options.isLocal=false] - Whether this is the local user
* @param {string} [options.handle] - Local actor handle (for local accounts)
* @returns {object} Mastodon Account entity
*/
export function serializeAccount(actor, { baseUrl, isLocal = false, handle = "" }) {
if (!actor) {
return null;
}
const id = accountId(actor, isLocal);
// Resolve username and acct
let username;
let acct;
if (isLocal) {
username = handle || extractUsername(actor.url) || "user";
acct = username; // local accounts use bare username
} else {
// Remote: extract from handle (@user@domain) or URL
const remoteHandle = actor.handle || "";
if (remoteHandle.startsWith("@")) {
username = remoteHandle.split("@")[1] || "";
acct = remoteHandle.slice(1); // strip leading @
} else if (remoteHandle.includes("@")) {
username = remoteHandle.split("@")[0];
acct = remoteHandle;
} else {
username = extractUsername(actor.url || actor.actorUrl) || "unknown";
const domain = extractDomain(actor.url || actor.actorUrl);
acct = domain ? `${username}@${domain}` : username;
}
}
// Resolve display name
const displayName = actor.name || actor.displayName || username || "";
// Resolve URLs for avatar and header
const avatarUrl =
actor.icon || actor.avatarUrl || actor.photo || actor.avatar || "";
const headerUrl = actor.image || actor.bannerUrl || "";
// Resolve URL
const url = actor.url || actor.actorUrl || "";
// Resolve note/summary
const note = actor.summary || "";
// Bot detection
const bot =
actor.bot === true ||
actor.actorType === "Service" ||
actor.actorType === "Application";
// Profile fields from attachments
const fields = (actor.attachments || actor.fields || []).map((f) => ({
name: f.name || "",
value: sanitizeHtml(f.value || ""),
verified_at: null,
}));
// Custom emojis
const emojis = (actor.emojis || []).map((e) => ({
shortcode: e.shortcode || "",
url: e.url || "",
static_url: e.url || "",
visible_in_picker: true,
}));
return {
id,
username,
acct,
url,
display_name: displayName,
note: sanitizeHtml(note),
avatar: avatarUrl || `${baseUrl}/images/default-avatar.svg`,
avatar_static: avatarUrl || `${baseUrl}/images/default-avatar.svg`,
header: headerUrl || "",
header_static: headerUrl || "",
locked: actor.manuallyApprovesFollowers || false,
fields,
emojis,
bot,
group: actor.actorType === "Group" || false,
discoverable: true,
noindex: false,
created_at: actor.createdAt || new Date().toISOString(),
last_status_at: actor.lastStatusAt || null,
statuses_count: actor.statusesCount || 0,
followers_count: actor.followersCount || 0,
following_count: actor.followingCount || 0,
// Enrich from cache if counts are 0 (embedded accounts in statuses lack counts)
...((!actor.statusesCount && !actor.followersCount && !isLocal)
? (() => {
const cached = getCachedAccountStats(url);
return cached
? {
statuses_count: cached.statusesCount || 0,
followers_count: cached.followersCount || 0,
following_count: cached.followingCount || 0,
created_at: cached.createdAt || actor.createdAt || new Date().toISOString(),
}
: {};
})()
: {}),
moved: actor.movedTo || null,
suspended: false,
limited: false,
memorial: false,
roles: [],
hide_collections: false,
};
}
/**
* Serialize the local profile as a CredentialAccount (includes source + role).
*
* @param {object} profile - ap_profile document
* @param {object} options
* @param {string} options.baseUrl - Server base URL
* @param {string} options.handle - Local actor handle
* @param {object} [options.counts] - { statuses, followers, following }
* @returns {object} Mastodon CredentialAccount entity
*/
export function serializeCredentialAccount(profile, { baseUrl, handle, counts = {} }) {
const account = serializeAccount(profile, {
baseUrl,
isLocal: true,
handle,
});
// Add counts if provided
account.statuses_count = counts.statuses || 0;
account.followers_count = counts.followers || 0;
account.following_count = counts.following || 0;
// CredentialAccount extensions
account.source = {
privacy: "public",
sensitive: false,
language: "",
note: stripHtml(profile.summary || ""),
fields: (profile.attachments || []).map((f) => ({
name: f.name || "",
value: f.value || "",
verified_at: null,
})),
follow_requests_count: 0,
};
account.role = {
id: "-99",
name: "",
permissions: "0",
color: "",
highlighted: false,
};
return account;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Extract username from a URL path.
* Handles /@username, /users/username patterns.
*/
function extractUsername(url) {
if (!url) return "";
try {
const { pathname } = new URL(url);
const atMatch = pathname.match(/\/@([^/]+)/);
if (atMatch) return atMatch[1];
const usersMatch = pathname.match(/\/users\/([^/]+)/);
if (usersMatch) return usersMatch[1];
return "";
} catch {
return "";
}
}
/**
* Extract domain from a URL.
*/
function extractDomain(url) {
if (!url) return "";
try {
return new URL(url).hostname;
} catch {
return "";
}
}

View File

@@ -0,0 +1 @@
// Instance v1/v2 serializer — implemented in Task 8

View File

@@ -0,0 +1,38 @@
/**
* MediaAttachment entity serializer for Mastodon Client API.
*
* Converts stored media metadata to Mastodon MediaAttachment shape.
*/
/**
* Serialize a MediaAttachment entity.
*
* @param {object} media - Media document from ap_media collection
* @returns {object} Mastodon MediaAttachment entity
*/
export function serializeMediaAttachment(media) {
const type = detectMediaType(media.contentType || media.type || "");
return {
id: media._id ? media._id.toString() : media.id || "",
type,
url: media.url || "",
preview_url: media.thumbnailUrl || media.url || "",
remote_url: null,
text_url: media.url || "",
meta: media.meta || {},
description: media.description || media.alt || null,
blurhash: media.blurhash || null,
};
}
/**
* Map MIME type or simple type string to Mastodon media type.
*/
function detectMediaType(contentType) {
if (contentType.startsWith("image/") || contentType === "image") return "image";
if (contentType.startsWith("video/") || contentType === "video") return "video";
if (contentType.startsWith("audio/") || contentType === "audio") return "audio";
if (contentType.startsWith("image/gif")) return "gifv";
return "unknown";
}

View File

@@ -0,0 +1,130 @@
/**
* Notification entity serializer for Mastodon Client API.
*
* Converts ap_notifications documents into the Mastodon Notification JSON shape.
*
* Internal type -> Mastodon type mapping:
* like -> favourite
* boost -> reblog
* follow -> follow
* reply -> mention
* mention -> mention
* dm -> mention (status will have visibility: "direct")
*/
import { serializeAccount } from "./account.js";
import { serializeStatus } from "./status.js";
import { encodeCursor } from "../helpers/pagination.js";
/**
* Map internal notification types to Mastodon API types.
*/
const TYPE_MAP = {
like: "favourite",
boost: "reblog",
follow: "follow",
follow_request: "follow_request",
reply: "mention",
mention: "mention",
dm: "mention",
report: "admin.report",
};
/**
* Serialize a notification document as a Mastodon Notification entity.
*
* @param {object} notif - ap_notifications document
* @param {object} options
* @param {string} options.baseUrl - Server base URL
* @param {Map<string, object>} [options.statusMap] - Pre-fetched statuses keyed by targetUrl
* @param {object} [options.interactionState] - { favouritedIds, rebloggedIds, bookmarkedIds }
* @returns {object|null} Mastodon Notification entity
*/
export function serializeNotification(notif, { baseUrl, statusMap, interactionState }) {
if (!notif) return null;
const mastodonType = TYPE_MAP[notif.type] || notif.type;
// Build the actor account from notification fields
const account = serializeAccount(
{
name: notif.actorName,
url: notif.actorUrl,
photo: notif.actorPhoto,
handle: notif.actorHandle,
},
{ baseUrl },
);
// Resolve the associated status (for favourite, reblog, mention types)
// For mention types, prefer the triggering post (notif.url) over the target post (notif.targetUrl)
// because targetUrl for replies points to the user's OWN post being replied to
let status = null;
if (statusMap) {
const isMentionType = mastodonType === "mention";
const lookupUrl = isMentionType
? (notif.url || notif.targetUrl)
: (notif.targetUrl || notif.url);
if (lookupUrl) {
const timelineItem = statusMap.get(lookupUrl);
if (timelineItem) {
status = serializeStatus(timelineItem, {
baseUrl,
favouritedIds: interactionState?.favouritedIds || new Set(),
rebloggedIds: interactionState?.rebloggedIds || new Set(),
bookmarkedIds: interactionState?.bookmarkedIds || new Set(),
pinnedIds: new Set(),
});
}
}
}
// For mentions/replies that don't have a matching timeline item,
// construct a minimal status from the notification content
if (!status && notif.content && (mastodonType === "mention")) {
status = {
id: notif._id.toString(),
created_at: notif.published || notif.createdAt || new Date().toISOString(),
in_reply_to_id: null,
in_reply_to_account_id: null,
sensitive: false,
spoiler_text: "",
visibility: notif.type === "dm" ? "direct" : "public",
language: null,
uri: notif.uid || "",
url: notif.url || notif.targetUrl || notif.uid || "",
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
edited_at: null,
favourited: false,
reblogged: false,
muted: false,
bookmarked: false,
pinned: false,
content: notif.content?.html || notif.content?.text || "",
filtered: null,
reblog: null,
application: null,
account,
media_attachments: [],
mentions: [],
tags: [],
emojis: [],
card: null,
poll: null,
};
}
const createdAt = notif.published instanceof Date
? notif.published.toISOString()
: notif.published || notif.createdAt || new Date().toISOString();
return {
id: encodeCursor(createdAt) || notif._id.toString(),
type: mastodonType,
created_at: createdAt,
account,
status,
};
}

View File

@@ -0,0 +1,38 @@
/**
* Relationship entity serializer for Mastodon Client API.
*
* Represents the relationship between the authenticated user
* and another account.
*/
/**
* Serialize a Relationship entity.
*
* @param {string} id - Account ID
* @param {object} state - Relationship state
* @param {boolean} [state.following=false]
* @param {boolean} [state.followed_by=false]
* @param {boolean} [state.blocking=false]
* @param {boolean} [state.muting=false]
* @param {boolean} [state.requested=false]
* @returns {object} Mastodon Relationship entity
*/
export function serializeRelationship(id, state = {}) {
return {
id,
following: state.following || false,
showing_reblogs: state.following || false,
notifying: false,
languages: [],
followed_by: state.followed_by || false,
blocking: state.blocking || false,
blocked_by: false,
muting: state.muting || false,
muting_notifications: state.muting || false,
requested: state.requested || false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
};
}

View File

@@ -0,0 +1,111 @@
/**
* XSS HTML sanitizer for Mastodon Client API responses.
*
* Strips dangerous HTML while preserving safe markup that
* Mastodon clients expect (links, paragraphs, line breaks,
* inline formatting, mentions, hashtags).
*/
/**
* Allowed HTML tags in Mastodon API content fields.
* Matches what Mastodon itself permits in status content.
*/
const ALLOWED_TAGS = new Set([
"a",
"br",
"p",
"span",
"strong",
"em",
"b",
"i",
"u",
"s",
"del",
"pre",
"code",
"blockquote",
"ul",
"ol",
"li",
]);
/**
* Allowed attributes per tag.
*/
const ALLOWED_ATTRS = {
a: new Set(["href", "rel", "class", "target"]),
span: new Set(["class"]),
};
/**
* Sanitize HTML content for safe inclusion in API responses.
*
* Strips all tags not in the allowlist and removes disallowed attributes.
* This is a lightweight sanitizer — for production, consider a
* battle-tested library like DOMPurify or sanitize-html.
*
* @param {string} html - Raw HTML string
* @returns {string} Sanitized HTML
*/
export function sanitizeHtml(html) {
if (!html || typeof html !== "string") return "";
return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => {
const tag = tagName.toLowerCase();
// Closing tag
if (match.startsWith("</")) {
return ALLOWED_TAGS.has(tag) ? `</${tag}>` : "";
}
// Opening tag — check if allowed
if (!ALLOWED_TAGS.has(tag)) return "";
// Self-closing br
if (tag === "br") return "<br>";
// Strip disallowed attributes
const allowedAttrs = ALLOWED_ATTRS[tag];
if (!allowedAttrs) return `<${tag}>`;
const attrs = [];
const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi;
let attrMatch;
while ((attrMatch = attrRegex.exec(match)) !== null) {
const attrName = attrMatch[1].toLowerCase();
if (attrName === tag) continue; // skip tag name itself
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
if (allowedAttrs.has(attrName)) {
// Block javascript: URIs in href
if (attrName === "href" && /^\s*javascript:/i.test(attrValue)) continue;
attrs.push(`${attrName}="${escapeAttr(attrValue)}"`);
}
}
return attrs.length > 0 ? `<${tag} ${attrs.join(" ")}>` : `<${tag}>`;
});
}
/**
* Escape HTML attribute value.
* @param {string} value
* @returns {string}
*/
function escapeAttr(value) {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* Strip all HTML tags, returning plain text.
* @param {string} html
* @returns {string}
*/
export function stripHtml(html) {
if (!html || typeof html !== "string") return "";
return html.replace(/<[^>]*>/g, "").trim();
}

View File

@@ -0,0 +1,312 @@
/**
* Status entity serializer for Mastodon Client API.
*
* Converts ap_timeline documents into the Mastodon Status JSON shape.
*
* CORRECTED field mappings (based on actual extractObjectData output):
* content <- content.html (NOT contentHtml)
* uri <- uid (NOT activityUrl)
* account <- author { name, url, photo, handle, emojis, bot }
* media <- photo[] + video[] + audio[] (NOT single attachments[])
* card <- linkPreviews[0] (NOT single card)
* tags <- category[] (NOT tags[])
* counts <- counts.boosts, counts.likes, counts.replies
* boost <- type:"boost" + boostedBy (flat, NOT nested sharedItem)
*/
import { serializeAccount } from "./account.js";
import { sanitizeHtml } from "./sanitize.js";
import { encodeCursor } from "../helpers/pagination.js";
// Module-level defaults set once at startup via setLocalIdentity()
let _localPublicationUrl = "";
let _localHandle = "";
/**
* Set the local identity for own-post detection.
* Called once during plugin init.
* @param {string} publicationUrl - e.g. "https://rmendes.net/"
* @param {string} handle - e.g. "rick"
*/
export function setLocalIdentity(publicationUrl, handle) {
_localPublicationUrl = publicationUrl;
_localHandle = handle;
}
/**
* Serialize an ap_timeline document as a Mastodon Status entity.
*
* @param {object} item - ap_timeline document
* @param {object} options
* @param {string} options.baseUrl - Server base URL
* @param {Set<string>} [options.favouritedIds] - UIDs the user has liked
* @param {Set<string>} [options.rebloggedIds] - UIDs the user has boosted
* @param {Set<string>} [options.bookmarkedIds] - UIDs the user has bookmarked
* @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned
* @returns {object} Mastodon Status entity
*/
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
if (!item) return null;
// Use published-based cursor as the status ID so pagination cursors
// (max_id/min_id) sort chronologically, not by insertion order.
const cursorDate = item.published || item.createdAt || item.boostedAt;
const id = encodeCursor(cursorDate) || item._id.toString();
const uid = item.uid || "";
const url = item.url || uid;
// Handle boosts — reconstruct nested reblog wrapper
if (item.type === "boost" && item.boostedBy) {
// The outer status represents the boost action
// The inner status is the original post (the item itself minus boost metadata)
const innerItem = { ...item, type: "note", boostedBy: undefined, boostedAt: undefined };
const innerStatus = serializeStatus(innerItem, {
baseUrl,
favouritedIds,
rebloggedIds,
bookmarkedIds,
pinnedIds,
});
return {
id,
created_at: item.boostedAt || item.createdAt || new Date().toISOString(),
in_reply_to_id: null,
in_reply_to_account_id: null,
sensitive: false,
spoiler_text: "",
visibility: item.visibility || "public",
language: null,
uri: uid,
url,
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
edited_at: null,
favourited: false,
reblogged: rebloggedIds?.has(uid) || false,
muted: false,
bookmarked: false,
pinned: false,
content: "",
filtered: null,
reblog: innerStatus,
application: null,
account: serializeAccount(item.boostedBy, { baseUrl }),
media_attachments: [],
mentions: [],
tags: [],
emojis: [],
card: null,
poll: null,
};
}
// Regular status (note, article, question)
const content = item.content?.html || item.content?.text || "";
const spoilerText = item.summary || "";
const sensitive = item.sensitive || false;
const visibility = item.visibility || "public";
const language = item.language || null;
const published = item.published || item.createdAt || new Date().toISOString();
const editedAt = item.updated || item.updatedAt || null;
// Media attachments — merge photo, video, audio arrays
const mediaAttachments = [];
let attachmentCounter = 0;
if (item.photo?.length > 0) {
for (const p of item.photo) {
mediaAttachments.push({
id: `${id}-${attachmentCounter++}`,
type: "image",
url: typeof p === "string" ? p : p.url,
preview_url: typeof p === "string" ? p : p.url,
remote_url: typeof p === "string" ? p : p.url,
text_url: null,
meta: buildImageMeta(p),
description: typeof p === "object" ? p.alt || "" : "",
blurhash: null,
});
}
}
if (item.video?.length > 0) {
for (const v of item.video) {
mediaAttachments.push({
id: `${id}-${attachmentCounter++}`,
type: "video",
url: typeof v === "string" ? v : v.url,
preview_url: typeof v === "string" ? v : v.url,
remote_url: typeof v === "string" ? v : v.url,
text_url: null,
meta: null,
description: typeof v === "object" ? v.alt || "" : "",
blurhash: null,
});
}
}
if (item.audio?.length > 0) {
for (const a of item.audio) {
mediaAttachments.push({
id: `${id}-${attachmentCounter++}`,
type: "audio",
url: typeof a === "string" ? a : a.url,
preview_url: typeof a === "string" ? a : a.url,
remote_url: typeof a === "string" ? a : a.url,
text_url: null,
meta: null,
description: typeof a === "object" ? a.alt || "" : "",
blurhash: null,
});
}
}
// Link preview -> card
const card = serializeCard(item.linkPreviews?.[0]);
// Tags from category[]
const tags = (item.category || []).map((tag) => ({
name: tag,
url: `${baseUrl}/tags/${encodeURIComponent(tag)}`,
}));
// Mentions
const mentions = (item.mentions || []).map((m) => ({
id: "0", // We don't have stable IDs for mentioned accounts
username: m.name || "",
url: m.url || "",
acct: m.name || "",
}));
// Custom emojis
const emojis = (item.emojis || []).map((e) => ({
shortcode: e.shortcode || "",
url: e.url || "",
static_url: e.url || "",
visible_in_picker: true,
}));
// Counts
const repliesCount = item.counts?.replies ?? 0;
const reblogsCount = item.counts?.boosts ?? 0;
const favouritesCount = item.counts?.likes ?? 0;
// Poll
const poll = serializePoll(item, id);
// Interaction state
const favourited = favouritedIds?.has(uid) || false;
const reblogged = rebloggedIds?.has(uid) || false;
const bookmarked = bookmarkedIds?.has(uid) || false;
const pinned = pinnedIds?.has(uid) || false;
return {
id,
created_at: published,
in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
in_reply_to_account_id: null, // TODO: resolve
sensitive,
spoiler_text: spoilerText,
visibility,
language,
uri: uid,
url,
replies_count: repliesCount,
reblogs_count: reblogsCount,
favourites_count: favouritesCount,
edited_at: editedAt || null,
favourited,
reblogged,
muted: false,
bookmarked,
pinned,
content: sanitizeHtml(content),
filtered: null,
reblog: null,
application: null,
account: item.author
? serializeAccount(item.author, {
baseUrl,
isLocal: !!(_localPublicationUrl && item.author.url === _localPublicationUrl),
handle: _localHandle,
})
: null,
media_attachments: mediaAttachments,
mentions,
tags,
emojis,
card,
poll,
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Serialize a linkPreview object as a Mastodon PreviewCard.
*/
function serializeCard(preview) {
if (!preview) return null;
return {
url: preview.url || "",
title: preview.title || "",
description: preview.description || "",
type: "link",
author_name: "",
author_url: "",
provider_name: preview.domain || "",
provider_url: "",
html: "",
width: 0,
height: 0,
image: preview.image || null,
embed_url: "",
blurhash: null,
language: null,
published_at: null,
};
}
/**
* Build image meta object for media attachments.
*/
function buildImageMeta(photo) {
if (typeof photo === "string") return null;
if (!photo.width && !photo.height) return null;
return {
original: {
width: photo.width || 0,
height: photo.height || 0,
size: photo.width && photo.height ? `${photo.width}x${photo.height}` : null,
aspect: photo.width && photo.height ? photo.width / photo.height : null,
},
};
}
/**
* Serialize poll data from a timeline item.
*/
function serializePoll(item, statusId) {
if (!item.pollOptions?.length) return null;
const totalVotes = item.pollOptions.reduce((sum, o) => sum + (o.votes || 0), 0);
return {
id: statusId,
expires_at: item.pollEndTime || null,
expired: item.pollClosed || false,
multiple: false,
votes_count: totalVotes,
voters_count: item.votersCount || null,
options: item.pollOptions.map((o) => ({
title: o.name || "",
votes_count: o.votes || 0,
})),
emojis: [],
voted: false,
own_votes: [],
};
}

View File

@@ -0,0 +1,51 @@
/**
* In-memory cache for remote account stats (followers, following, statuses).
*
* Populated by resolveRemoteAccount() when a profile is fetched.
* Read by serializeAccount() to enrich embedded account objects in statuses.
*
* LRU-style with TTL — entries expire after 1 hour.
*/
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_ENTRIES = 500;
// Map<actorUrl, { followersCount, followingCount, statusesCount, createdAt, cachedAt }>
const cache = new Map();
/**
* Store account stats in cache.
* @param {string} actorUrl - The actor's URL (cache key)
* @param {object} stats - { followersCount, followingCount, statusesCount, createdAt }
*/
export function cacheAccountStats(actorUrl, stats) {
if (!actorUrl) return;
// Evict oldest if at capacity
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
cache.delete(oldest);
}
cache.set(actorUrl, { ...stats, cachedAt: Date.now() });
}
/**
* Get cached account stats.
* @param {string} actorUrl - The actor's URL
* @returns {object|null} Stats or null if not cached/expired
*/
export function getCachedAccountStats(actorUrl) {
if (!actorUrl) return null;
const entry = cache.get(actorUrl);
if (!entry) return null;
// Check TTL
if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
cache.delete(actorUrl);
return null;
}
return entry;
}

View File

@@ -0,0 +1,32 @@
/**
* Deterministic ID mapping for Mastodon Client API.
*
* Local accounts use MongoDB _id.toString().
* Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs
* without requiring a dedicated accounts collection.
*/
import crypto from "node:crypto";
/**
* Generate a deterministic ID for a remote actor URL.
* @param {string} actorUrl - The remote actor's URL
* @returns {string} 24-character hex ID
*/
export function remoteActorId(actorUrl) {
return crypto.createHash("sha256").update(actorUrl).digest("hex").slice(0, 24);
}
/**
* Get the Mastodon API ID for an account.
* @param {object} actor - Actor object (local profile or remote author)
* @param {boolean} isLocal - Whether this is the local profile
* @returns {string}
*/
export function accountId(actor, isLocal = false) {
if (isLocal && actor._id) {
return actor._id.toString();
}
// Remote actors: use URL-based deterministic hash
const url = actor.url || actor.actorUrl || "";
return url ? remoteActorId(url) : "0";
}

View File

@@ -0,0 +1,278 @@
/**
* Shared interaction logic for like/unlike, boost/unboost, bookmark/unbookmark.
*
* Extracted from admin controllers (interactions-like.js, interactions-boost.js)
* so that both the admin UI and Mastodon Client API can reuse the same core logic.
*
* Each function accepts a context object instead of Express req/res,
* making them transport-agnostic.
*/
import { resolveAuthor } from "../../resolve-author.js";
/**
* Like a post — send Like activity and track in ap_interactions.
*
* @param {object} params
* @param {string} params.targetUrl - URL of the post to like
* @param {object} params.federation - Fedify federation instance
* @param {string} params.handle - Local actor handle
* @param {string} params.publicationUrl - Publication base URL
* @param {object} params.collections - MongoDB collections (Map or object)
* @param {object} params.interactions - ap_interactions collection
* @returns {Promise<{ activityId: string }>}
*/
export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
const { Like } = await import("@fedify/fedify/vocab");
const ctx = federation.createContext(
new URL(publicationUrl),
{ handle, publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
const uuid = crypto.randomUUID();
const baseUrl = publicationUrl.replace(/\/$/, "");
const activityId = `${baseUrl}/activitypub/likes/${uuid}`;
const like = new Like({
id: new URL(activityId),
actor: ctx.getActorUri(handle),
object: new URL(targetUrl),
});
if (recipient) {
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: targetUrl,
});
}
if (interactions) {
await interactions.updateOne(
{ objectUrl: targetUrl, type: "like" },
{
$set: {
objectUrl: targetUrl,
type: "like",
activityId,
recipientUrl: recipient?.id?.href || "",
createdAt: new Date().toISOString(),
},
},
{ upsert: true },
);
}
return { activityId };
}
/**
* Unlike a post — send Undo(Like) activity and remove from ap_interactions.
*
* @param {object} params
* @param {string} params.targetUrl - URL of the post to unlike
* @param {object} params.federation - Fedify federation instance
* @param {string} params.handle - Local actor handle
* @param {string} params.publicationUrl - Publication base URL
* @param {object} params.collections - MongoDB collections
* @param {object} params.interactions - ap_interactions collection
* @returns {Promise<void>}
*/
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
const existing = interactions
? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
: null;
if (!existing) {
return;
}
const { Like, Undo } = await import("@fedify/fedify/vocab");
const ctx = federation.createContext(
new URL(publicationUrl),
{ handle, publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
if (recipient) {
const like = new Like({
id: existing.activityId ? new URL(existing.activityId) : undefined,
actor: ctx.getActorUri(handle),
object: new URL(targetUrl),
});
const undo = new Undo({
actor: ctx.getActorUri(handle),
object: like,
});
await ctx.sendActivity({ identifier: handle }, recipient, undo, {
orderingKey: targetUrl,
});
}
if (interactions) {
await interactions.deleteOne({ objectUrl: targetUrl, type: "like" });
}
}
/**
* Boost a post — send Announce activity and track in ap_interactions.
*
* @param {object} params
* @param {string} params.targetUrl - URL of the post to boost
* @param {object} params.federation - Fedify federation instance
* @param {string} params.handle - Local actor handle
* @param {string} params.publicationUrl - Publication base URL
* @param {object} params.collections - MongoDB collections
* @param {object} params.interactions - ap_interactions collection
* @returns {Promise<{ activityId: string }>}
*/
export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
const { Announce } = await import("@fedify/fedify/vocab");
const ctx = federation.createContext(
new URL(publicationUrl),
{ handle, publicationUrl },
);
const uuid = crypto.randomUUID();
const baseUrl = publicationUrl.replace(/\/$/, "");
const activityId = `${baseUrl}/activitypub/boosts/${uuid}`;
const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public");
const followersUri = ctx.getFollowersUri(handle);
const announce = new Announce({
id: new URL(activityId),
actor: ctx.getActorUri(handle),
object: new URL(targetUrl),
to: publicAddress,
cc: followersUri,
});
// Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: targetUrl,
});
// Also send directly to the original post author
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
if (recipient) {
try {
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
orderingKey: targetUrl,
});
} catch {
// Non-critical — follower delivery already happened
}
}
if (interactions) {
await interactions.updateOne(
{ objectUrl: targetUrl, type: "boost" },
{
$set: {
objectUrl: targetUrl,
type: "boost",
activityId,
createdAt: new Date().toISOString(),
},
},
{ upsert: true },
);
}
return { activityId };
}
/**
* Unboost a post — send Undo(Announce) activity and remove from ap_interactions.
*
* @param {object} params
* @param {string} params.targetUrl - URL of the post to unboost
* @param {object} params.federation - Fedify federation instance
* @param {string} params.handle - Local actor handle
* @param {string} params.publicationUrl - Publication base URL
* @param {object} params.interactions - ap_interactions collection
* @returns {Promise<void>}
*/
export async function unboostPost({ targetUrl, federation, handle, publicationUrl, interactions }) {
const existing = interactions
? await interactions.findOne({ objectUrl: targetUrl, type: "boost" })
: null;
if (!existing) {
return;
}
const { Announce, Undo } = await import("@fedify/fedify/vocab");
const ctx = federation.createContext(
new URL(publicationUrl),
{ handle, publicationUrl },
);
const announce = new Announce({
id: existing.activityId ? new URL(existing.activityId) : undefined,
actor: ctx.getActorUri(handle),
object: new URL(targetUrl),
});
const undo = new Undo({
actor: ctx.getActorUri(handle),
object: announce,
});
await ctx.sendActivity({ identifier: handle }, "followers", undo, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: targetUrl,
});
if (interactions) {
await interactions.deleteOne({ objectUrl: targetUrl, type: "boost" });
}
}
/**
* Bookmark a post — local-only, no federation.
*
* @param {object} params
* @param {string} params.targetUrl - URL of the post to bookmark
* @param {object} params.interactions - ap_interactions collection
* @returns {Promise<void>}
*/
export async function bookmarkPost({ targetUrl, interactions }) {
if (!interactions) return;
await interactions.updateOne(
{ objectUrl: targetUrl, type: "bookmark" },
{
$set: {
objectUrl: targetUrl,
type: "bookmark",
createdAt: new Date().toISOString(),
},
},
{ upsert: true },
);
}
/**
* Remove a bookmark — local-only, no federation.
*
* @param {object} params
* @param {string} params.targetUrl - URL of the post to unbookmark
* @param {object} params.interactions - ap_interactions collection
* @returns {Promise<void>}
*/
export async function unbookmarkPost({ targetUrl, interactions }) {
if (!interactions) return;
await interactions.deleteOne({ objectUrl: targetUrl, type: "bookmark" });
}

View File

@@ -0,0 +1,153 @@
/**
* Mastodon-compatible cursor pagination helpers.
*
* Uses `published` date as cursor (chronologically correct) instead of
* MongoDB ObjectId. ObjectId reflects insertion order, not publication
* order — backfilled or syndicated posts get new ObjectIds at import
* time, breaking chronological sort. The `published` field matches the
* native reader's sort and produces a correct timeline.
*
* Cursor values are `published` ISO strings, but Mastodon clients pass
* them as opaque `max_id`/`min_id`/`since_id` strings. We encode the
* published date as a Mastodon-style snowflake-ish ID (milliseconds
* since epoch) so clients treat them as comparable integers.
*
* Emits RFC 8288 Link headers that masto.js / Phanpy parse.
*/
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 40;
/**
* Encode a published date string as a numeric cursor ID.
* Mastodon clients expect IDs to be numeric strings that sort chronologically.
* We use milliseconds since epoch — monotonic and comparable.
*
* @param {string|Date} published - ISO date string or Date object
* @returns {string} Numeric string (ms since epoch)
*/
export function encodeCursor(published) {
if (!published) return "0";
const ms = new Date(published).getTime();
return Number.isFinite(ms) ? String(ms) : "0";
}
/**
* Decode a numeric cursor ID back to an ISO date string.
*
* @param {string} cursor - Numeric cursor from client
* @returns {string|null} ISO date string, or null if invalid
*/
export function decodeCursor(cursor) {
if (!cursor) return null;
const ms = Number.parseInt(cursor, 10);
if (!Number.isFinite(ms) || ms <= 0) return null;
return new Date(ms).toISOString();
}
/**
* Parse and clamp the limit parameter.
*
* @param {string|number} raw - Raw limit value from query string
* @returns {number}
*/
export function parseLimit(raw) {
const n = Number.parseInt(String(raw), 10);
if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
return Math.min(n, MAX_LIMIT);
}
/**
* Build a MongoDB filter object for cursor-based pagination.
*
* Mastodon cursor params (all optional, applied to `published`):
* max_id — return items older than this cursor (exclusive)
* min_id — return items newer than this cursor (exclusive), closest first
* since_id — return items newer than this cursor (exclusive), most recent first
*
* @param {object} baseFilter - Existing MongoDB filter to extend
* @param {object} cursors
* @param {string} [cursors.max_id] - Numeric cursor (ms since epoch)
* @param {string} [cursors.min_id] - Numeric cursor (ms since epoch)
* @param {string} [cursors.since_id] - Numeric cursor (ms since epoch)
* @returns {{ filter: object, sort: object, reverse: boolean }}
*/
export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
const filter = { ...baseFilter };
let sort = { published: -1 }; // newest first (default)
let reverse = false;
if (max_id) {
const date = decodeCursor(max_id);
if (date) {
filter.published = { ...filter.published, $lt: date };
}
}
if (since_id) {
const date = decodeCursor(since_id);
if (date) {
filter.published = { ...filter.published, $gt: date };
}
}
if (min_id) {
const date = decodeCursor(min_id);
if (date) {
filter.published = { ...filter.published, $gt: date };
// min_id returns results closest to the cursor, so sort ascending
// then reverse the results before returning
sort = { published: 1 };
reverse = true;
}
}
return { filter, sort, reverse };
}
/**
* Set the Link pagination header on an Express response.
*
* @param {object} res - Express response object
* @param {object} req - Express request object (for building URLs)
* @param {Array} items - Result items (must have `published`)
* @param {number} limit - The limit used for the query
*/
export function setPaginationHeaders(res, req, items, limit) {
if (!items?.length) return;
// Only emit Link if we got a full page (may have more)
if (items.length < limit) return;
const firstCursor = encodeCursor(items[0].published);
const lastCursor = encodeCursor(items[items.length - 1].published);
if (firstCursor === "0" || lastCursor === "0") return;
const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
// Preserve existing query params (like types[] for notifications)
const existingParams = new URLSearchParams();
for (const [key, value] of Object.entries(req.query)) {
if (key === "max_id" || key === "min_id" || key === "since_id") continue;
if (Array.isArray(value)) {
for (const v of value) existingParams.append(key, v);
} else {
existingParams.set(key, String(value));
}
}
const links = [];
// rel="next" — older items (max_id = last item's cursor)
const nextParams = new URLSearchParams(existingParams);
nextParams.set("max_id", lastCursor);
links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
// rel="prev" — newer items (min_id = first item's cursor)
const prevParams = new URLSearchParams(existingParams);
prevParams.set("min_id", firstCursor);
links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
res.set("Link", links.join(", "));
}

View File

@@ -0,0 +1,132 @@
/**
* Resolve a remote account via WebFinger + ActivityPub actor fetch.
* Uses the Fedify federation instance to perform discovery.
*
* Shared by accounts.js (lookup) and search.js (resolve=true).
*/
import { serializeAccount } from "../entities/account.js";
import { cacheAccountStats } from "./account-cache.js";
/**
* @param {string} acct - Account identifier (user@domain or URL)
* @param {object} pluginOptions - Plugin options with federation, handle, publicationUrl
* @param {string} baseUrl - Server base URL
* @returns {Promise<object|null>} Serialized Mastodon Account or null
*/
export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
const { federation, handle, publicationUrl } = pluginOptions;
if (!federation) return null;
try {
const ctx = federation.createContext(
new URL(publicationUrl),
{ handle, publicationUrl },
);
// Determine lookup URI
let actorUri;
if (acct.includes("@")) {
const parts = acct.replace(/^@/, "").split("@");
const username = parts[0];
const domain = parts[1];
if (!username || !domain) return null;
actorUri = `acct:${username}@${domain}`;
} else if (acct.startsWith("http")) {
actorUri = acct;
} else {
return null;
}
const actor = await ctx.lookupObject(actorUri);
if (!actor) return null;
// Extract data from the Fedify actor object
const name = actor.name?.toString() || actor.preferredUsername?.toString() || "";
const actorUrl = actor.id?.href || "";
const username = actor.preferredUsername?.toString() || "";
const domain = actorUrl ? new URL(actorUrl).hostname : "";
const summary = actor.summary?.toString() || "";
// Get avatar
let avatarUrl = "";
try {
const icon = await actor.getIcon();
avatarUrl = icon?.url?.href || "";
} catch { /* ignore */ }
// Get header image
let headerUrl = "";
try {
const image = await actor.getImage();
headerUrl = image?.url?.href || "";
} catch { /* ignore */ }
// Get collection counts (followers, following, outbox)
let followersCount = 0;
let followingCount = 0;
let statusesCount = 0;
try {
const followers = await actor.getFollowers();
if (followers?.totalItems != null) followersCount = followers.totalItems;
} catch { /* ignore */ }
try {
const following = await actor.getFollowing();
if (following?.totalItems != null) followingCount = following.totalItems;
} catch { /* ignore */ }
try {
const outbox = await actor.getOutbox();
if (outbox?.totalItems != null) statusesCount = outbox.totalItems;
} catch { /* ignore */ }
// Get published/created date
const published = actor.published
? String(actor.published)
: null;
// Profile fields from attachments
const fields = [];
try {
for await (const attachment of actor.getAttachments()) {
if (attachment?.name) {
fields.push({
name: attachment.name?.toString() || "",
value: attachment.value?.toString() || "",
});
}
}
} catch { /* ignore */ }
const account = serializeAccount(
{
name,
url: actorUrl,
photo: avatarUrl,
handle: `@${username}@${domain}`,
summary,
image: headerUrl,
bot: actor.constructor?.name === "Service" || actor.constructor?.name === "Application",
attachments: fields.length > 0 ? fields : undefined,
createdAt: published || undefined,
},
{ baseUrl },
);
// Override counts with real data from AP collections
account.followers_count = followersCount;
account.following_count = followingCount;
account.statuses_count = statusesCount;
// Cache stats so embedded account objects in statuses can use them
cacheAccountStats(actorUrl, {
followersCount,
followingCount,
statusesCount,
createdAt: published || undefined,
});
return account;
} catch (error) {
console.warn(`[Mastodon API] Remote account resolution failed for ${acct}:`, error.message);
return null;
}
}

View File

@@ -0,0 +1,25 @@
/**
* CORS middleware for Mastodon Client API routes.
*
* Mandatory for browser-based SPA clients like Phanpy that make
* cross-origin requests. Without this, the browser's Same-Origin
* Policy blocks all API calls.
*/
const ALLOWED_METHODS = "GET, HEAD, POST, PUT, DELETE, PATCH";
const ALLOWED_HEADERS = "Authorization, Content-Type, Idempotency-Key";
const EXPOSED_HEADERS = "Link";
export function corsMiddleware(req, res, next) {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", ALLOWED_METHODS);
res.set("Access-Control-Allow-Headers", ALLOWED_HEADERS);
res.set("Access-Control-Expose-Headers", EXPOSED_HEADERS);
// Handle preflight requests
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
}

View File

@@ -0,0 +1,37 @@
/**
* Error handling middleware for Mastodon Client API routes.
*
* Ensures all errors return JSON in Mastodon's expected format
* instead of HTML error pages that masto.js cannot parse.
*
* Standard format: { "error": "description" }
* OAuth format: { "error": "error_type", "error_description": "..." }
*/
// eslint-disable-next-line no-unused-vars
export function errorHandler(err, req, res, _next) {
const status = err.status || err.statusCode || 500;
// OAuth errors use RFC 6749 format
if (err.oauthError) {
return res.status(status).json({
error: err.oauthError,
error_description: err.message || "An error occurred",
});
}
// Standard Mastodon error format
res.status(status).json({
error: err.message || "An unexpected error occurred",
});
}
/**
* 501 catch-all for unimplemented API endpoints.
* Must be mounted AFTER all implemented routes.
*/
export function notImplementedHandler(req, res) {
res.status(501).json({
error: "Not implemented",
});
}

View File

@@ -0,0 +1,86 @@
/**
* Scope enforcement middleware for Mastodon Client API.
*
* Supports scope hierarchy: parent scope covers all children.
* "read" grants "read:accounts", "read:statuses", etc.
* "write" grants "write:statuses", "write:favourites", etc.
*
* Legacy "follow" scope maps to read/write for blocks, follows, and mutes.
*/
/**
* Scopes that the legacy "follow" scope grants access to.
*/
const FOLLOW_SCOPE_EXPANSION = [
"read:blocks",
"write:blocks",
"read:follows",
"write:follows",
"read:mutes",
"write:mutes",
];
/**
* Create middleware that checks if the token has the required scope.
*
* @param {...string} requiredScopes - One or more scopes (any match = pass)
* @returns {Function} Express middleware
*/
export function scopeRequired(...requiredScopes) {
return (req, res, next) => {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({
error: "The access token is invalid",
});
}
const grantedScopes = token.scopes || [];
const hasScope = requiredScopes.some((required) =>
checkScope(grantedScopes, required),
);
if (!hasScope) {
return res.status(403).json({
error: `This action is outside the authorized scopes. Required: ${requiredScopes.join(" or ")}`,
});
}
next();
};
}
/**
* Check if granted scopes satisfy a required scope.
*
* Rules:
* - Exact match: "read:accounts" satisfies "read:accounts"
* - Parent match: "read" satisfies "read:accounts"
* - "follow" expands to read/write for blocks, follows, mutes
* - "profile" satisfies "read:accounts" (for verify_credentials)
*
* @param {string[]} granted - Scopes on the token
* @param {string} required - Scope being checked
* @returns {boolean}
*/
function checkScope(granted, required) {
// Exact match
if (granted.includes(required)) return true;
// Parent scope: "read" covers "read:*", "write" covers "write:*"
const [parent] = required.split(":");
if (parent && granted.includes(parent)) return true;
// Legacy "follow" scope expansion
if (granted.includes("follow") && FOLLOW_SCOPE_EXPANSION.includes(required)) {
return true;
}
// "profile" scope can satisfy "read:accounts"
if (required === "read:accounts" && granted.includes("profile")) {
return true;
}
return false;
}

View File

@@ -0,0 +1,57 @@
/**
* Bearer token validation middleware for Mastodon Client API.
*
* Extracts the Bearer token from the Authorization header,
* validates it against the ap_oauth_tokens collection,
* and attaches token data to `req.mastodonToken`.
*/
/**
* Require a valid Bearer token. Returns 401 if invalid/missing.
*/
export async function tokenRequired(req, res, next) {
const token = await resolveToken(req);
if (!token) {
return res.status(401).json({
error: "The access token is invalid",
});
}
req.mastodonToken = token;
next();
}
/**
* Optional token — sets req.mastodonToken to null if absent.
* For public endpoints that personalize when authenticated.
*/
export async function optionalToken(req, res, next) {
req.mastodonToken = await resolveToken(req);
next();
}
/**
* Extract and validate Bearer token from request.
* @returns {object|null} Token document or null
*/
async function resolveToken(req) {
const authHeader = req.get("authorization");
if (!authHeader?.startsWith("Bearer ")) return null;
const accessToken = authHeader.slice(7);
if (!accessToken) return null;
const collections = req.app.locals.mastodonCollections;
const token = await collections.ap_oauth_tokens.findOne({
accessToken,
revokedAt: null,
});
if (!token) return null;
// Check expiry if set
if (token.expiresAt && token.expiresAt < new Date()) return null;
return token;
}

96
lib/mastodon/router.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* Mastodon Client API — main router.
*
* Combines all sub-routers, applies CORS and error handling middleware.
* Mounted at "/" via Indiekit.addEndpoint() so Mastodon clients can access
* /api/v1/*, /api/v2/*, /oauth/* at the domain root.
*/
import express from "express";
import { corsMiddleware } from "./middleware/cors.js";
import { tokenRequired, optionalToken } from "./middleware/token-required.js";
import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js";
// Route modules
import oauthRouter from "./routes/oauth.js";
import instanceRouter from "./routes/instance.js";
import accountsRouter from "./routes/accounts.js";
import statusesRouter from "./routes/statuses.js";
import timelinesRouter from "./routes/timelines.js";
import notificationsRouter from "./routes/notifications.js";
import searchRouter from "./routes/search.js";
import mediaRouter from "./routes/media.js";
import stubsRouter from "./routes/stubs.js";
/**
* Create the combined Mastodon API router.
*
* @param {object} options
* @param {object} options.collections - MongoDB collections object
* @param {object} [options.pluginOptions] - Plugin options (handle, etc.)
* @returns {import("express").Router} Express router
*/
export function createMastodonRouter({ collections, pluginOptions = {} }) {
const router = express.Router(); // eslint-disable-line new-cap
// ─── Body parsers ───────────────────────────────────────────────────────
// Mastodon clients send JSON, form-urlencoded, and occasionally text/plain.
// These must be applied before route handlers.
router.use("/api", express.json());
router.use("/api", express.urlencoded({ extended: true }));
router.use("/oauth", express.json());
router.use("/oauth", express.urlencoded({ extended: true }));
// ─── CORS ───────────────────────────────────────────────────────────────
router.use("/api", corsMiddleware);
router.use("/oauth/token", corsMiddleware);
router.use("/oauth/revoke", corsMiddleware);
router.use("/.well-known/oauth-authorization-server", corsMiddleware);
// ─── Inject collections + plugin options into req ───────────────────────
router.use("/api", (req, res, next) => {
req.app.locals.mastodonCollections = collections;
req.app.locals.mastodonPluginOptions = pluginOptions;
next();
});
router.use("/oauth", (req, res, next) => {
req.app.locals.mastodonCollections = collections;
req.app.locals.mastodonPluginOptions = pluginOptions;
next();
});
router.use("/.well-known/oauth-authorization-server", (req, res, next) => {
req.app.locals.mastodonCollections = collections;
req.app.locals.mastodonPluginOptions = pluginOptions;
next();
});
// ─── Token resolution ───────────────────────────────────────────────────
// Apply optional token resolution to all API routes so handlers can check
// req.mastodonToken. Specific routes that require auth use tokenRequired.
router.use("/api", optionalToken);
// ─── OAuth routes (no token required for most) ──────────────────────────
router.use(oauthRouter);
// ─── Public API routes (no auth required) ───────────────────────────────
router.use(instanceRouter);
// ─── Authenticated API routes ───────────────────────────────────────────
router.use(accountsRouter);
router.use(statusesRouter);
router.use(timelinesRouter);
router.use(notificationsRouter);
router.use(searchRouter);
router.use(mediaRouter);
router.use(stubsRouter);
// ─── Catch-all for unimplemented endpoints ──────────────────────────────
// Express 5 path-to-regexp v8: use {*name} for wildcard
router.all("/api/v1/{*rest}", notImplementedHandler);
router.all("/api/v2/{*rest}", notImplementedHandler);
// ─── Error handler ──────────────────────────────────────────────────────
router.use("/api", errorHandler);
router.use("/oauth", errorHandler);
return router;
}

View File

@@ -0,0 +1,810 @@
/**
* Account endpoints for Mastodon Client API.
*
* Phase 1: verify_credentials, preferences, account lookup
* Phase 2: relationships, follow/unfollow, account statuses
*/
import express from "express";
import { serializeCredentialAccount, serializeAccount } from "../entities/account.js";
import { serializeStatus } from "../entities/status.js";
import { accountId, remoteActorId } from "../helpers/id-mapping.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/accounts/verify_credentials ─────────────────────────────────
router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const baseUrl = `${req.protocol}://${req.get("host")}`;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const handle = pluginOptions.handle || "user";
const profile = await collections.ap_profile.findOne({});
if (!profile) {
return res.status(404).json({ error: "Profile not found" });
}
// Get counts
let counts = {};
try {
const [statuses, followers, following] = await Promise.all([
collections.ap_timeline.countDocuments({
"author.url": profile.url,
}),
collections.ap_followers.countDocuments({}),
collections.ap_following.countDocuments({}),
]);
counts = { statuses, followers, following };
} catch {
counts = { statuses: 0, followers: 0, following: 0 };
}
const account = serializeCredentialAccount(profile, {
baseUrl,
handle,
counts,
});
res.json(account);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/preferences ─────────────────────────────────────────────────
router.get("/api/v1/preferences", (req, res) => {
res.json({
"posting:default:visibility": "public",
"posting:default:sensitive": false,
"posting:default:language": "en",
"reading:expand:media": "default",
"reading:expand:spoilers": false,
});
});
// ─── GET /api/v1/accounts/lookup ─────────────────────────────────────────────
router.get("/api/v1/accounts/lookup", async (req, res, next) => {
try {
const { acct } = req.query;
if (!acct) {
return res.status(400).json({ error: "Missing acct parameter" });
}
const baseUrl = `${req.protocol}://${req.get("host")}`;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const handle = pluginOptions.handle || "user";
// Check if looking up local account
const bareAcct = acct.startsWith("@") ? acct.slice(1) : acct;
const localDomain = req.get("host");
if (
bareAcct === handle ||
bareAcct === `${handle}@${localDomain}`
) {
const profile = await collections.ap_profile.findOne({});
if (profile) {
return res.json(
serializeAccount(profile, { baseUrl, isLocal: true, handle }),
);
}
}
// Check followers for known remote actors
const follower = await collections.ap_followers.findOne({
$or: [
{ handle: `@${bareAcct}` },
{ handle: bareAcct },
],
});
if (follower) {
return res.json(
serializeAccount(
{ name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle, bannerUrl: follower.banner || "" },
{ baseUrl },
),
);
}
// Check following
const following = await collections.ap_following.findOne({
$or: [
{ handle: `@${bareAcct}` },
{ handle: bareAcct },
],
});
if (following) {
return res.json(
serializeAccount(
{ name: following.name, url: following.actorUrl, photo: following.avatar, handle: following.handle },
{ baseUrl },
),
);
}
// Check timeline authors (people whose posts are in our timeline)
const timelineAuthor = await collections.ap_timeline.findOne({
"author.handle": { $in: [`@${bareAcct}`, bareAcct] },
});
if (timelineAuthor?.author) {
return res.json(
serializeAccount(timelineAuthor.author, { baseUrl }),
);
}
// Resolve remotely via federation (WebFinger + actor fetch)
const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl);
if (resolved) {
return res.json(resolved);
}
return res.status(404).json({ error: "Record not found" });
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
// MUST be before /accounts/:id to prevent Express matching "relationships" as :id
router.get("/api/v1/accounts/relationships", async (req, res, next) => {
try {
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
if (ids.length === 0) {
return res.json([]);
}
const collections = req.app.locals.mastodonCollections;
const [followers, following, blocked, muted] = await Promise.all([
collections.ap_followers.find({}).toArray(),
collections.ap_following.find({}).toArray(),
collections.ap_blocked.find({}).toArray(),
collections.ap_muted.find({}).toArray(),
]);
const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
const followingIds = new Set(following.map((f) => remoteActorId(f.actorUrl)));
const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
const relationships = ids.map((id) => ({
id,
following: followingIds.has(id),
showing_reblogs: followingIds.has(id),
notifying: false,
languages: [],
followed_by: followerIds.has(id),
blocking: blockedIds.has(id),
blocked_by: false,
muting: mutedIds.has(id),
muting_notifications: mutedIds.has(id),
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
}));
res.json(relationships);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
// MUST be before /accounts/:id
router.get("/api/v1/accounts/familiar_followers", (req, res) => {
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
res.json(ids.map((id) => ({ id, accounts: [] })));
});
// ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
router.get("/api/v1/accounts/:id", async (req, res, next) => {
try {
const { id } = req.params;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const handle = pluginOptions.handle || "user";
// Check if it's the local profile
const profile = await collections.ap_profile.findOne({});
if (profile && profile._id.toString() === id) {
const [statuses, followers, following] = await Promise.all([
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
collections.ap_followers.countDocuments({}),
collections.ap_following.countDocuments({}),
]);
const account = serializeAccount(profile, { baseUrl, isLocal: true, handle });
account.statuses_count = statuses;
account.followers_count = followers;
account.following_count = following;
return res.json(account);
}
// Resolve remote actor from followers, following, or timeline
const { actor, actorUrl } = await resolveActorData(id, collections);
if (actor) {
// Try remote resolution to get real counts (followers, following, statuses)
const remoteAccount = await resolveRemoteAccount(
actorUrl,
pluginOptions,
baseUrl,
);
if (remoteAccount) {
return res.json(remoteAccount);
}
// Fallback to local data
const account = serializeAccount(actor, { baseUrl });
account.statuses_count = await collections.ap_timeline.countDocuments({
"author.url": actorUrl,
});
return res.json(account);
}
return res.status(404).json({ error: "Record not found" });
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
// Resolve account ID to an author URL
const actorUrl = await resolveActorUrl(id, collections);
if (!actorUrl) {
return res.status(404).json({ error: "Record not found" });
}
// Build filter for this author's posts
const baseFilter = {
"author.url": actorUrl,
isContext: { $ne: true },
};
// Mastodon filters
if (req.query.only_media === "true") {
baseFilter.$or = [
{ "photo.0": { $exists: true } },
{ "video.0": { $exists: true } },
{ "audio.0": { $exists: true } },
];
}
if (req.query.exclude_replies === "true") {
baseFilter.inReplyTo = { $exists: false };
}
if (req.query.exclude_reblogs === "true") {
baseFilter.type = { $ne: "boost" };
}
if (req.query.pinned === "true") {
baseFilter.pinned = true;
}
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
let items = await collections.ap_timeline
.find(filter)
.sort(sort)
.limit(limit)
.toArray();
if (reverse) {
items.reverse();
}
// Load interaction state if authenticated
let favouritedIds = new Set();
let rebloggedIds = new Set();
let bookmarkedIds = new Set();
if (req.mastodonToken && collections.ap_interactions) {
const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
if (lookupUrls.length > 0) {
const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: lookupUrls } })
.toArray();
for (const ix of interactions) {
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
}
}
}
const statuses = items.map((item) =>
serializeStatus(item, {
baseUrl,
favouritedIds,
rebloggedIds,
bookmarkedIds,
pinnedIds: new Set(),
}),
);
setPaginationHeaders(res, req, items, limit);
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
const profile = await collections.ap_profile.findOne({});
// Only serve followers for the local account
if (!profile || profile._id.toString() !== id) {
return res.json([]);
}
const followers = await collections.ap_followers
.find({})
.limit(limit)
.toArray();
const accounts = followers.map((f) =>
serializeAccount(
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
{ baseUrl },
),
);
res.json(accounts);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
const profile = await collections.ap_profile.findOne({});
// Only serve following for the local account
if (!profile || profile._id.toString() !== id) {
return res.json([]);
}
const following = await collections.ap_following
.find({})
.limit(limit)
.toArray();
const accounts = following.map((f) =>
serializeAccount(
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
{ baseUrl },
),
);
res.json(accounts);
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
// Resolve the account ID to an actor URL
const actorUrl = await resolveActorUrl(id, collections);
if (!actorUrl) {
return res.status(404).json({ error: "Record not found" });
}
// Use the plugin's followActor method
if (pluginOptions.followActor) {
const result = await pluginOptions.followActor(actorUrl);
if (!result.ok) {
return res.status(422).json({ error: result.error || "Follow failed" });
}
}
// Return relationship
const followingIds = new Set();
const following = await collections.ap_following.find({}).toArray();
for (const f of following) {
followingIds.add(remoteActorId(f.actorUrl));
}
const followerIds = new Set();
const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) {
followerIds.add(remoteActorId(f.actorUrl));
}
res.json({
id,
following: true,
showing_reblogs: true,
notifying: false,
languages: [],
followed_by: followerIds.has(id),
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/accounts/:id/unfollow ─────────────────────────────────────
router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const actorUrl = await resolveActorUrl(id, collections);
if (!actorUrl) {
return res.status(404).json({ error: "Record not found" });
}
if (pluginOptions.unfollowActor) {
const result = await pluginOptions.unfollowActor(actorUrl);
if (!result.ok) {
return res.status(422).json({ error: result.error || "Unfollow failed" });
}
}
const followerIds = new Set();
const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) {
followerIds.add(remoteActorId(f.actorUrl));
}
res.json({
id,
following: false,
showing_reblogs: true,
notifying: false,
languages: [],
followed_by: followerIds.has(id),
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/accounts/:id/mute ────────────────────────────────────────
router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const actorUrl = await resolveActorUrl(id, collections);
if (actorUrl && collections.ap_muted) {
await collections.ap_muted.updateOne(
{ url: actorUrl },
{ $set: { url: actorUrl, createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
res.json({
id,
following: false,
showing_reblogs: true,
notifying: false,
languages: [],
followed_by: false,
blocking: false,
blocked_by: false,
muting: true,
muting_notifications: true,
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/accounts/:id/unmute ───────────────────────────────────────
router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const actorUrl = await resolveActorUrl(id, collections);
if (actorUrl && collections.ap_muted) {
await collections.ap_muted.deleteOne({ url: actorUrl });
}
res.json({
id,
following: false,
showing_reblogs: true,
notifying: false,
languages: [],
followed_by: false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/accounts/:id/block ───────────────────────────────────────
router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const actorUrl = await resolveActorUrl(id, collections);
if (actorUrl && collections.ap_blocked) {
await collections.ap_blocked.updateOne(
{ url: actorUrl },
{ $set: { url: actorUrl, createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
res.json({
id,
following: false,
showing_reblogs: true,
notifying: false,
languages: [],
followed_by: false,
blocking: true,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/accounts/:id/unblock ──────────────────────────────────────
router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const actorUrl = await resolveActorUrl(id, collections);
if (actorUrl && collections.ap_blocked) {
await collections.ap_blocked.deleteOne({ url: actorUrl });
}
res.json({
id,
following: false,
showing_reblogs: true,
notifying: false,
languages: [],
followed_by: false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
});
} catch (error) {
next(error);
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Resolve an account ID back to an actor URL by scanning followers/following.
*/
async function resolveActorUrl(id, collections) {
// Check if it's the local profile
const profile = await collections.ap_profile.findOne({});
if (profile && profile._id.toString() === id) {
return profile.url;
}
// Check followers
const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) {
if (remoteActorId(f.actorUrl) === id) {
return f.actorUrl;
}
}
// Check following
const following = await collections.ap_following.find({}).toArray();
for (const f of following) {
if (remoteActorId(f.actorUrl) === id) {
return f.actorUrl;
}
}
// Check timeline authors
const timelineItems = await collections.ap_timeline
.find({ "author.url": { $exists: true } })
.project({ "author.url": 1 })
.toArray();
const seenUrls = new Set();
for (const item of timelineItems) {
const authorUrl = item.author?.url;
if (!authorUrl || seenUrls.has(authorUrl)) continue;
seenUrls.add(authorUrl);
if (remoteActorId(authorUrl) === id) {
return authorUrl;
}
}
return null;
}
/**
* Resolve an account ID to both actor data and URL.
* Returns { actor, actorUrl } or { actor: null, actorUrl: null }.
*/
async function resolveActorData(id, collections) {
// Check followers — pass through all stored fields for richer serialization
const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) {
if (remoteActorId(f.actorUrl) === id) {
return {
actor: {
name: f.name,
url: f.actorUrl,
photo: f.avatar,
handle: f.handle,
bannerUrl: f.banner || "",
},
actorUrl: f.actorUrl,
};
}
}
// Check following — pass through all stored fields
const following = await collections.ap_following.find({}).toArray();
for (const f of following) {
if (remoteActorId(f.actorUrl) === id) {
return {
actor: {
name: f.name,
url: f.actorUrl,
photo: f.avatar,
handle: f.handle,
bannerUrl: f.banner || "",
},
actorUrl: f.actorUrl,
};
}
}
// Check timeline authors
const timelineItems = await collections.ap_timeline
.find({ "author.url": { $exists: true } })
.project({ author: 1 })
.toArray();
const seenUrls = new Set();
for (const item of timelineItems) {
const authorUrl = item.author?.url;
if (!authorUrl || seenUrls.has(authorUrl)) continue;
seenUrls.add(authorUrl);
if (remoteActorId(authorUrl) === id) {
return { actor: item.author, actorUrl: authorUrl };
}
}
return { actor: null, actorUrl: null };
}
export default router;

View File

@@ -0,0 +1,207 @@
/**
* Instance info endpoints for Mastodon Client API.
*
* GET /api/v2/instance — v2 format (primary)
* GET /api/v1/instance — v1 format (fallback for older clients)
*/
import express from "express";
import { serializeAccount } from "../entities/account.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v2/instance ────────────────────────────────────────────────────
router.get("/api/v2/instance", async (req, res, next) => {
try {
const baseUrl = `${req.protocol}://${req.get("host")}`;
const domain = req.get("host");
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const profile = await collections.ap_profile.findOne({});
const contactAccount = profile
? serializeAccount(profile, {
baseUrl,
isLocal: true,
handle: pluginOptions.handle || "user",
})
: null;
res.json({
domain,
title: profile?.name || domain,
version: "4.0.0 (compatible; Indiekit ActivityPub)",
source_url: "https://github.com/getindiekit/indiekit",
description: profile?.summary || `An Indiekit instance at ${domain}`,
usage: {
users: {
active_month: 1,
},
},
thumbnail: {
url: profile?.icon || `${baseUrl}/favicon.ico`,
blurhash: null,
versions: {},
},
icon: [],
languages: ["en"],
configuration: {
urls: {
streaming: "",
},
accounts: {
max_featured_tags: 10,
max_pinned_statuses: 10,
},
statuses: {
max_characters: 5000,
max_media_attachments: 4,
characters_reserved_per_url: 23,
},
media_attachments: {
supported_mime_types: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
],
image_size_limit: 16_777_216,
image_matrix_limit: 16_777_216,
video_size_limit: 67_108_864,
video_frame_rate_limit: 60,
video_matrix_limit: 16_777_216,
},
polls: {
max_options: 4,
max_characters_per_option: 50,
min_expiration: 300,
max_expiration: 2_592_000,
},
translation: {
enabled: false,
},
vapid: {
public_key: "",
},
},
registrations: {
enabled: false,
approval_required: true,
message: null,
url: null,
},
api_versions: {
mastodon: 0,
},
contact: {
email: "",
account: contactAccount,
},
rules: [],
});
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/instance ────────────────────────────────────────────────────
router.get("/api/v1/instance", async (req, res, next) => {
try {
const baseUrl = `${req.protocol}://${req.get("host")}`;
const domain = req.get("host");
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const profile = await collections.ap_profile.findOne({});
// Get approximate counts
let statusCount = 0;
let domainCount = 0;
try {
statusCount = await collections.ap_timeline.countDocuments({});
// Rough domain count from unique follower domains
const followers = await collections.ap_followers
.find({}, { projection: { actorUrl: 1 } })
.toArray();
const domains = new Set(
followers
.map((f) => {
try {
return new URL(f.actorUrl).hostname;
} catch {
return null;
}
})
.filter(Boolean),
);
domainCount = domains.size;
} catch {
// Non-critical
}
res.json({
uri: domain,
title: profile?.name || domain,
short_description: profile?.summary || "",
description: profile?.summary || `An Indiekit instance at ${domain}`,
email: "",
version: "4.0.0 (compatible; Indiekit ActivityPub)",
urls: {
streaming_api: "",
},
stats: {
user_count: 1,
status_count: statusCount,
domain_count: domainCount,
},
thumbnail: profile?.icon || null,
languages: ["en"],
registrations: false,
approval_required: true,
invites_enabled: false,
configuration: {
statuses: {
max_characters: 5000,
max_media_attachments: 4,
characters_reserved_per_url: 23,
},
media_attachments: {
supported_mime_types: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
],
image_size_limit: 16_777_216,
image_matrix_limit: 16_777_216,
video_size_limit: 67_108_864,
video_frame_rate_limit: 60,
video_matrix_limit: 16_777_216,
},
polls: {
max_options: 4,
max_characters_per_option: 50,
min_expiration: 300,
max_expiration: 2_592_000,
},
},
contact_account: profile
? serializeAccount(profile, {
baseUrl,
isLocal: true,
handle: pluginOptions.handle || "user",
})
: null,
rules: [],
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,43 @@
/**
* Media endpoints for Mastodon Client API.
*
* POST /api/v2/media — upload media attachment (stub — returns 422 until storage is configured)
* POST /api/v1/media — legacy upload endpoint (redirects to v2)
* GET /api/v1/media/:id — get media attachment status
* PUT /api/v1/media/:id — update media metadata (description/focus)
*/
import express from "express";
const router = express.Router(); // eslint-disable-line new-cap
// ─── POST /api/v2/media ─────────────────────────────────────────────────────
router.post("/api/v2/media", (req, res) => {
// Media upload requires multer/multipart handling + storage backend.
// For now, return 422 so clients show a user-friendly error.
res.status(422).json({
error: "Media uploads are not yet supported on this server",
});
});
// ─── POST /api/v1/media (legacy) ────────────────────────────────────────────
router.post("/api/v1/media", (req, res) => {
res.status(422).json({
error: "Media uploads are not yet supported on this server",
});
});
// ─── GET /api/v1/media/:id ──────────────────────────────────────────────────
router.get("/api/v1/media/:id", (req, res) => {
res.status(404).json({ error: "Record not found" });
});
// ─── PUT /api/v1/media/:id ──────────────────────────────────────────────────
router.put("/api/v1/media/:id", (req, res) => {
res.status(404).json({ error: "Record not found" });
});
export default router;

View File

@@ -0,0 +1,257 @@
/**
* Notification endpoints for Mastodon Client API.
*
* GET /api/v1/notifications — list notifications with pagination
* GET /api/v1/notifications/:id — single notification
* POST /api/v1/notifications/clear — clear all notifications
* POST /api/v1/notifications/:id/dismiss — dismiss single notification
*/
import express from "express";
import { ObjectId } from "mongodb";
import { serializeNotification } from "../entities/notification.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
const router = express.Router(); // eslint-disable-line new-cap
/**
* Mastodon type -> internal type reverse mapping for filtering.
*/
const REVERSE_TYPE_MAP = {
favourite: "like",
reblog: "boost",
follow: "follow",
follow_request: "follow_request",
mention: { $in: ["reply", "mention", "dm"] },
poll: "poll",
update: "update",
"admin.report": "report",
};
// ─── GET /api/v1/notifications ──────────────────────────────────────────────
router.get("/api/v1/notifications", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
// Build base filter
const baseFilter = {};
// types[] — include only these Mastodon types
const includeTypes = normalizeArray(req.query["types[]"] || req.query.types);
if (includeTypes.length > 0) {
const internalTypes = resolveInternalTypes(includeTypes);
if (internalTypes.length > 0) {
baseFilter.type = { $in: internalTypes };
}
}
// exclude_types[] — exclude these Mastodon types
const excludeTypes = normalizeArray(req.query["exclude_types[]"] || req.query.exclude_types);
if (excludeTypes.length > 0) {
const excludeInternal = resolveInternalTypes(excludeTypes);
if (excludeInternal.length > 0) {
baseFilter.type = { ...baseFilter.type, $nin: excludeInternal };
}
}
// Apply cursor pagination
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
let items = await collections.ap_notifications
.find(filter)
.sort(sort)
.limit(limit)
.toArray();
if (reverse) {
items.reverse();
}
// Batch-fetch referenced timeline items to avoid N+1
const statusMap = await batchFetchStatuses(collections, items);
// Serialize notifications
const notifications = items.map((notif) =>
serializeNotification(notif, {
baseUrl,
statusMap,
interactionState: {
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
},
}),
).filter(Boolean);
// Set pagination headers
setPaginationHeaders(res, req, items, limit);
res.json(notifications);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/notifications/:id ──────────────────────────────────────────
router.get("/api/v1/notifications/:id", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
let objectId;
try {
objectId = new ObjectId(req.params.id);
} catch {
return res.status(404).json({ error: "Record not found" });
}
const notif = await collections.ap_notifications.findOne({ _id: objectId });
if (!notif) {
return res.status(404).json({ error: "Record not found" });
}
const statusMap = await batchFetchStatuses(collections, [notif]);
const notification = serializeNotification(notif, {
baseUrl,
statusMap,
interactionState: {
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
},
});
res.json(notification);
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/notifications/clear ───────────────────────────────────────
router.post("/api/v1/notifications/clear", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
await collections.ap_notifications.deleteMany({});
res.json({});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/notifications/:id/dismiss ─────────────────────────────────
router.post("/api/v1/notifications/:id/dismiss", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
let objectId;
try {
objectId = new ObjectId(req.params.id);
} catch {
return res.status(404).json({ error: "Record not found" });
}
await collections.ap_notifications.deleteOne({ _id: objectId });
res.json({});
} catch (error) {
next(error);
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Normalize query param to array (handles string or array).
*/
function normalizeArray(param) {
if (!param) return [];
return Array.isArray(param) ? param : [param];
}
/**
* Convert Mastodon notification types to internal types.
*/
function resolveInternalTypes(mastodonTypes) {
const result = [];
for (const t of mastodonTypes) {
const mapped = REVERSE_TYPE_MAP[t];
if (mapped) {
if (mapped.$in) {
result.push(...mapped.$in);
} else {
result.push(mapped);
}
}
}
return result;
}
/**
* Batch-fetch timeline items referenced by notifications.
*
* @param {object} collections
* @param {Array} notifications
* @returns {Promise<Map<string, object>>} Map of targetUrl -> timeline item
*/
async function batchFetchStatuses(collections, notifications) {
const statusMap = new Map();
const targetUrls = [
...new Set(
notifications
.map((n) => n.targetUrl)
.filter(Boolean),
),
];
if (targetUrls.length === 0 || !collections.ap_timeline) {
return statusMap;
}
const items = await collections.ap_timeline
.find({
$or: [
{ uid: { $in: targetUrls } },
{ url: { $in: targetUrls } },
],
})
.toArray();
for (const item of items) {
if (item.uid) statusMap.set(item.uid, item);
if (item.url) statusMap.set(item.url, item);
}
return statusMap;
}
export default router;

View File

@@ -0,0 +1,635 @@
/**
* OAuth2 routes for Mastodon Client API.
*
* Handles app registration, authorization, token exchange, and revocation.
*/
import crypto from "node:crypto";
import express from "express";
const router = express.Router(); // eslint-disable-line new-cap
/**
* Generate cryptographically random hex string.
* @param {number} bytes - Number of random bytes
* @returns {string} Hex-encoded random string
*/
function randomHex(bytes) {
return crypto.randomBytes(bytes).toString("hex");
}
/**
* Parse redirect_uris from request — accepts space-separated string or array.
* @param {string|string[]} value
* @returns {string[]}
*/
function parseRedirectUris(value) {
if (!value) return ["urn:ietf:wg:oauth:2.0:oob"];
if (Array.isArray(value)) return value.map((v) => v.trim());
return value
.trim()
.split(/\s+/)
.filter(Boolean);
}
/**
* Parse scopes from request — accepts space-separated string.
* @param {string} value
* @returns {string[]}
*/
function parseScopes(value) {
if (!value) return ["read"];
return value
.trim()
.split(/\s+/)
.filter(Boolean);
}
// ─── POST /api/v1/apps — Register client application ────────────────────────
router.post("/api/v1/apps", async (req, res, next) => {
try {
const { client_name, redirect_uris, scopes, website } = req.body;
const clientId = randomHex(16);
const clientSecret = randomHex(32);
const redirectUris = parseRedirectUris(redirect_uris);
const parsedScopes = parseScopes(scopes);
const doc = {
clientId,
clientSecret,
name: client_name || "",
redirectUris,
scopes: parsedScopes,
website: website || null,
confidential: true,
createdAt: new Date(),
};
const collections = req.app.locals.mastodonCollections;
await collections.ap_oauth_apps.insertOne(doc);
res.json({
id: doc._id?.toString() || clientId,
name: doc.name,
website: doc.website,
redirect_uris: redirectUris,
redirect_uri: redirectUris.join(" "),
client_id: clientId,
client_secret: clientSecret,
client_secret_expires_at: 0,
vapid_key: "",
});
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/apps/verify_credentials ─────────────────────────────────────
router.get("/api/v1/apps/verify_credentials", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
const app = await collections.ap_oauth_apps.findOne({
clientId: token.clientId,
});
if (!app) {
return res.status(404).json({ error: "Application not found" });
}
res.json({
id: app._id.toString(),
name: app.name,
website: app.website,
scopes: app.scopes,
redirect_uris: app.redirectUris,
redirect_uri: app.redirectUris.join(" "),
});
} catch (error) {
next(error);
}
});
// ─── GET /.well-known/oauth-authorization-server ─────────────────────────────
router.get("/.well-known/oauth-authorization-server", (req, res) => {
const baseUrl = `${req.protocol}://${req.get("host")}`;
res.json({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/oauth/token`,
revocation_endpoint: `${baseUrl}/oauth/revoke`,
scopes_supported: [
"read",
"write",
"follow",
"push",
"profile",
"read:accounts",
"read:blocks",
"read:bookmarks",
"read:favourites",
"read:filters",
"read:follows",
"read:lists",
"read:mutes",
"read:notifications",
"read:search",
"read:statuses",
"write:accounts",
"write:blocks",
"write:bookmarks",
"write:conversations",
"write:favourites",
"write:filters",
"write:follows",
"write:lists",
"write:media",
"write:mutes",
"write:notifications",
"write:reports",
"write:statuses",
],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "client_credentials", "refresh_token"],
token_endpoint_auth_methods_supported: [
"client_secret_basic",
"client_secret_post",
"none",
],
code_challenge_methods_supported: ["S256"],
service_documentation: "https://docs.joinmastodon.org/api/",
app_registration_endpoint: `${baseUrl}/api/v1/apps`,
});
});
// ─── GET /oauth/authorize — Show authorization page ──────────────────────────
router.get("/oauth/authorize", async (req, res, next) => {
try {
let {
client_id,
redirect_uri,
response_type,
scope,
code_challenge,
code_challenge_method,
force_login,
} = req.query;
// Restore OAuth params from session after login redirect.
// Indiekit's login flow doesn't re-encode the redirect param, so query
// params with & are stripped during the /session/login → /session/auth
// round-trip. We store them in the session before redirecting.
if (!response_type && req.session?.pendingOAuth) {
const p = req.session.pendingOAuth;
delete req.session.pendingOAuth;
client_id = p.client_id;
redirect_uri = p.redirect_uri;
response_type = p.response_type;
scope = p.scope;
code_challenge = p.code_challenge;
code_challenge_method = p.code_challenge_method;
}
if (response_type !== "code") {
return res.status(400).json({
error: "unsupported_response_type",
error_description: "Only response_type=code is supported",
});
}
const collections = req.app.locals.mastodonCollections;
const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
if (!app) {
return res.status(400).json({
error: "invalid_client",
error_description: "Client application not found",
});
}
// Determine redirect URI — use provided or default to first registered
const resolvedRedirectUri =
redirect_uri || app.redirectUris[0] || "urn:ietf:wg:oauth:2.0:oob";
// Validate redirect_uri is registered
if (!app.redirectUris.includes(resolvedRedirectUri)) {
return res.status(400).json({
error: "invalid_redirect_uri",
error_description: "Redirect URI not registered for this application",
});
}
// Validate requested scopes are subset of app scopes
const requestedScopes = scope ? scope.split(/\s+/) : app.scopes;
// Check if user is logged in via IndieAuth session
const session = req.session;
if (!session?.access_token && !force_login) {
// Store OAuth params in session — they won't survive Indiekit's
// login redirect chain due to a re-encoding bug in indieauth.js.
req.session.pendingOAuth = {
client_id, redirect_uri, response_type, scope,
code_challenge, code_challenge_method,
};
// Redirect to Indiekit's login page with a simple return path.
return res.redirect("/session/login?redirect=/oauth/authorize");
}
// Render simple authorization page
const appName = app.name || "An application";
res.type("html").send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize ${appName}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.4rem; }
.scopes { background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; }
.scopes code { display: block; margin: 0.25rem 0; }
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
button { padding: 0.6rem 1.5rem; border-radius: 6px; font-size: 1rem; cursor: pointer; border: 1px solid #ccc; }
.approve { background: #2b90d9; color: white; border-color: #2b90d9; }
.deny { background: white; }
</style>
</head>
<body>
<h1>Authorize ${appName}</h1>
<p>${appName} wants to access your account with these permissions:</p>
<div class="scopes">
${requestedScopes.map((s) => `<code>${s}</code>`).join("")}
</div>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${client_id}">
<input type="hidden" name="redirect_uri" value="${resolvedRedirectUri}">
<input type="hidden" name="scope" value="${requestedScopes.join(" ")}">
<input type="hidden" name="code_challenge" value="${code_challenge || ""}">
<input type="hidden" name="code_challenge_method" value="${code_challenge_method || ""}">
<input type="hidden" name="response_type" value="code">
<div class="actions">
<button type="submit" name="decision" value="approve" class="approve">Authorize</button>
<button type="submit" name="decision" value="deny" class="deny">Deny</button>
</div>
</form>
</body>
</html>`);
} catch (error) {
next(error);
}
});
// ─── POST /oauth/authorize — Process authorization decision ──────────────────
router.post("/oauth/authorize", async (req, res, next) => {
try {
const {
client_id,
redirect_uri,
scope,
code_challenge,
code_challenge_method,
decision,
} = req.body;
// User denied
if (decision === "deny") {
if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") {
const url = new URL(redirect_uri);
url.searchParams.set("error", "access_denied");
url.searchParams.set(
"error_description",
"The resource owner denied the request",
);
return redirectToUri(res, redirect_uri, url.toString());
}
return res.status(403).json({
error: "access_denied",
error_description: "The resource owner denied the request",
});
}
// Generate authorization code
const code = randomHex(32);
const collections = req.app.locals.mastodonCollections;
// Note: accessToken is NOT set here — it's added later during token exchange.
// The sparse unique index on accessToken skips documents where the field is
// absent, allowing multiple auth codes to coexist. Setting it to null would
// cause E11000 duplicate key errors because MongoDB sparse indexes still
// enforce uniqueness on explicit null values.
await collections.ap_oauth_tokens.insertOne({
code,
clientId: client_id,
scopes: scope ? scope.split(/\s+/) : ["read"],
redirectUri: redirect_uri,
codeChallenge: code_challenge || null,
codeChallengeMethod: code_challenge_method || null,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
});
// Out-of-band: show code on page
if (!redirect_uri || redirect_uri === "urn:ietf:wg:oauth:2.0:oob") {
return res.type("html").send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorization Code</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
code { display: block; background: #f5f5f5; padding: 1rem; border-radius: 6px; word-break: break-all; margin: 1rem 0; }
</style>
</head>
<body>
<h1>Authorization Code</h1>
<p>Copy this code and paste it into the application:</p>
<code>${code}</code>
</body>
</html>`);
}
// Redirect with code
const url = new URL(redirect_uri);
url.searchParams.set("code", code);
redirectToUri(res, redirect_uri, url.toString());
} catch (error) {
next(error);
}
});
// ─── POST /oauth/token — Exchange code for access token ──────────────────────
router.post("/oauth/token", async (req, res, next) => {
try {
const { grant_type, code, redirect_uri, code_verifier } = req.body;
// Extract client credentials from request (3 methods)
const { clientId, clientSecret } = extractClientCredentials(req);
const collections = req.app.locals.mastodonCollections;
if (grant_type === "client_credentials") {
// Client credentials grant — limited access for pre-login API calls
if (!clientId || !clientSecret) {
return res.status(401).json({
error: "invalid_client",
error_description: "Client authentication required",
});
}
const app = await collections.ap_oauth_apps.findOne({
clientId,
clientSecret,
confidential: true,
});
if (!app) {
return res.status(401).json({
error: "invalid_client",
error_description: "Invalid client credentials",
});
}
// No code field — this is a direct token grant, not a code exchange.
// Omitting code (instead of setting null) avoids sparse index collisions.
const accessToken = randomHex(64);
await collections.ap_oauth_tokens.insertOne({
clientId,
scopes: ["read"],
accessToken,
createdAt: new Date(),
grantType: "client_credentials",
});
return res.json({
access_token: accessToken,
token_type: "Bearer",
scope: "read",
created_at: Math.floor(Date.now() / 1000),
});
}
// ─── Refresh token grant ──────────────────────────────────────────
if (grant_type === "refresh_token") {
const { refresh_token } = req.body;
if (!refresh_token) {
return res.status(400).json({
error: "invalid_request",
error_description: "Missing refresh_token",
});
}
const existing = await collections.ap_oauth_tokens.findOne({
refreshToken: refresh_token,
revokedAt: null,
});
if (!existing) {
return res.status(400).json({
error: "invalid_grant",
error_description: "Refresh token is invalid or revoked",
});
}
// Rotate: new access token + new refresh token
const newAccessToken = randomHex(64);
const newRefreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne(
{ _id: existing._id },
{ $set: { accessToken: newAccessToken, refreshToken: newRefreshToken } },
);
return res.json({
access_token: newAccessToken,
token_type: "Bearer",
scope: existing.scopes.join(" "),
created_at: Math.floor(existing.createdAt.getTime() / 1000),
refresh_token: newRefreshToken,
});
}
if (grant_type !== "authorization_code") {
return res.status(400).json({
error: "unsupported_grant_type",
error_description: "Only authorization_code, client_credentials, and refresh_token are supported",
});
}
if (!code) {
return res.status(400).json({
error: "invalid_request",
error_description: "Missing authorization code",
});
}
// Atomic claim-or-fail: find the code and mark it used in one operation
const grant = await collections.ap_oauth_tokens.findOneAndUpdate(
{
code,
usedAt: null,
revokedAt: null,
expiresAt: { $gt: new Date() },
},
{ $set: { usedAt: new Date() } },
{ returnDocument: "before" },
);
if (!grant) {
return res.status(400).json({
error: "invalid_grant",
error_description:
"Authorization code is invalid, expired, or already used",
});
}
// Validate redirect_uri matches
if (redirect_uri && grant.redirectUri && redirect_uri !== grant.redirectUri) {
return res.status(400).json({
error: "invalid_grant",
error_description: "Redirect URI mismatch",
});
}
// Verify PKCE code_verifier if code_challenge was stored
if (grant.codeChallenge) {
if (!code_verifier) {
return res.status(400).json({
error: "invalid_grant",
error_description: "Missing code_verifier for PKCE",
});
}
const expectedChallenge = crypto
.createHash("sha256")
.update(code_verifier)
.digest("base64url");
if (expectedChallenge !== grant.codeChallenge) {
return res.status(400).json({
error: "invalid_grant",
error_description: "Invalid code_verifier",
});
}
}
// Generate access token and refresh token.
// Clear expiresAt — it was set for the auth code, not the access token.
const accessToken = randomHex(64);
const refreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne(
{ _id: grant._id },
{ $set: { accessToken, refreshToken, expiresAt: null } },
);
res.json({
access_token: accessToken,
token_type: "Bearer",
scope: grant.scopes.join(" "),
created_at: Math.floor(grant.createdAt.getTime() / 1000),
refresh_token: refreshToken,
});
} catch (error) {
next(error);
}
});
// ─── POST /oauth/revoke — Revoke a token ────────────────────────────────────
router.post("/oauth/revoke", async (req, res, next) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({
error: "invalid_request",
error_description: "Missing token parameter",
});
}
const collections = req.app.locals.mastodonCollections;
// Match by access token or refresh token
await collections.ap_oauth_tokens.updateOne(
{ $or: [{ accessToken: token }, { refreshToken: token }] },
{ $set: { revokedAt: new Date() } },
);
// RFC 7009: always return 200 even if token wasn't found
res.json({});
} catch (error) {
next(error);
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Extract client credentials from request using 3 methods:
* 1. HTTP Basic Auth (client_secret_basic)
* 2. POST body (client_secret_post)
* 3. client_id only (none — public clients)
*/
function extractClientCredentials(req) {
// Method 1: HTTP Basic Auth
const authHeader = req.get("authorization");
if (authHeader?.startsWith("Basic ")) {
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
const colonIndex = decoded.indexOf(":");
if (colonIndex > 0) {
return {
clientId: decoded.slice(0, colonIndex),
clientSecret: decoded.slice(colonIndex + 1),
};
}
}
// Method 2 & 3: POST body
return {
clientId: req.body.client_id || null,
clientSecret: req.body.client_secret || null,
};
}
/**
* Redirect to a URI, handling custom schemes for native apps.
*
* HTTP(S) redirect URIs use a standard 302 redirect (web clients).
* Custom scheme URIs (fedilab://, moshidon-android-auth://) use an
* HTML page with JavaScript + meta refresh. Android Chrome Custom Tabs
* block 302 redirects to non-HTTP schemes but allow client-side navigation.
*
* @param {object} res - Express response
* @param {string} originalUri - The registered redirect_uri (to detect scheme)
* @param {string} fullUrl - The complete redirect URL with query params
*/
function redirectToUri(res, originalUri, fullUrl) {
if (originalUri.startsWith("http://") || originalUri.startsWith("https://")) {
return res.redirect(fullUrl);
}
// Native app — HTML page with JS redirect + meta refresh fallback
res.type("html").send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0;url=${fullUrl}">
<title>Redirecting…</title>
</head>
<body>
<p>Redirecting to application…</p>
<script>window.location.href = ${JSON.stringify(fullUrl)};</script>
</body>
</html>`);
}
export default router;

View File

@@ -0,0 +1,158 @@
/**
* Search endpoint for Mastodon Client API.
*
* GET /api/v2/search — search accounts, statuses, and hashtags
*/
import express from "express";
import { serializeStatus } from "../entities/status.js";
import { serializeAccount } from "../entities/account.js";
import { parseLimit } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v2/search ─────────────────────────────────────────────────────
router.get("/api/v2/search", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const query = (req.query.q || "").trim();
const type = req.query.type; // "accounts", "statuses", "hashtags", or undefined (all)
const limit = parseLimit(req.query.limit);
const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
const resolve = req.query.resolve === "true";
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
if (!query) {
return res.json({ accounts: [], statuses: [], hashtags: [] });
}
const results = { accounts: [], statuses: [], hashtags: [] };
// ─── Account search ──────────────────────────────────────────────────
if (!type || type === "accounts") {
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const nameRegex = new RegExp(escapedQuery, "i");
// Search followers and following by display name or handle
const accountDocs = [];
if (collections.ap_followers) {
const followers = await collections.ap_followers
.find({
$or: [
{ name: nameRegex },
{ preferredUsername: nameRegex },
{ url: nameRegex },
],
})
.limit(limit)
.toArray();
accountDocs.push(...followers);
}
if (collections.ap_following) {
const following = await collections.ap_following
.find({
$or: [
{ name: nameRegex },
{ preferredUsername: nameRegex },
{ url: nameRegex },
],
})
.limit(limit)
.toArray();
accountDocs.push(...following);
}
// Deduplicate by URL
const seen = new Set();
for (const doc of accountDocs) {
const url = doc.url || doc.id;
if (url && !seen.has(url)) {
seen.add(url);
results.accounts.push(
serializeAccount(doc, { baseUrl, isRemote: true }),
);
}
if (results.accounts.length >= limit) break;
}
// If no local results and resolve=true, try remote lookup
if (results.accounts.length === 0 && resolve && query.includes("@")) {
const resolved = await resolveRemoteAccount(query, pluginOptions, baseUrl);
if (resolved) {
results.accounts.push(resolved);
}
}
}
// ─── Status search ───────────────────────────────────────────────────
if (!type || type === "statuses") {
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const contentRegex = new RegExp(escapedQuery, "i");
const items = await collections.ap_timeline
.find({
isContext: { $ne: true },
$or: [
{ "content.text": contentRegex },
{ "content.html": contentRegex },
],
})
.sort({ _id: -1 })
.skip(offset)
.limit(limit)
.toArray();
results.statuses = items.map((item) =>
serializeStatus(item, {
baseUrl,
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
}),
);
}
// ─── Hashtag search ──────────────────────────────────────────────────
if (!type || type === "hashtags") {
const escapedQuery = query
.replace(/^#/, "")
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const tagRegex = new RegExp(escapedQuery, "i");
// Find distinct category values matching the query
const allCategories = await collections.ap_timeline.distinct("category", {
category: tagRegex,
});
// Flatten and deduplicate (category can be string or array)
const tagSet = new Set();
for (const cat of allCategories) {
if (Array.isArray(cat)) {
for (const c of cat) {
if (typeof c === "string" && tagRegex.test(c)) tagSet.add(c);
}
} else if (typeof cat === "string" && tagRegex.test(cat)) {
tagSet.add(cat);
}
}
results.hashtags = [...tagSet].slice(0, limit).map((name) => ({
name,
url: `${baseUrl}/tags/${encodeURIComponent(name)}`,
history: [],
}));
}
res.json(results);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,630 @@
/**
* Status endpoints for Mastodon Client API.
*
* GET /api/v1/statuses/:id — single status
* GET /api/v1/statuses/:id/context — thread context (ancestors + descendants)
* POST /api/v1/statuses — create post via Micropub pipeline
* DELETE /api/v1/statuses/:id — delete post via Micropub pipeline
* POST /api/v1/statuses/:id/favourite — like a post
* POST /api/v1/statuses/:id/unfavourite — unlike a post
* POST /api/v1/statuses/:id/reblog — boost a post
* POST /api/v1/statuses/:id/unreblog — unboost a post
* POST /api/v1/statuses/:id/bookmark — bookmark a post
* POST /api/v1/statuses/:id/unbookmark — remove bookmark
*/
import express from "express";
import { ObjectId } from "mongodb";
import { serializeStatus } from "../entities/status.js";
import { decodeCursor } from "../helpers/pagination.js";
import {
likePost, unlikePost,
boostPost, unboostPost,
bookmarkPost, unbookmarkPost,
} from "../helpers/interactions.js";
import { addTimelineItem } from "../../storage/timeline.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/statuses/:id ───────────────────────────────────────────────
router.get("/api/v1/statuses/:id", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(collections.ap_timeline, id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
// Load interaction state if authenticated
const interactionState = await loadItemInteractions(collections, item);
const status = serializeStatus(item, {
baseUrl,
...interactionState,
pinnedIds: new Set(),
});
res.json(status);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/statuses/:id/context ───────────────────────────────────────
router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(collections.ap_timeline, id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
// Find ancestors: walk up the inReplyTo chain
const ancestors = [];
let currentReplyTo = item.inReplyTo;
const visited = new Set();
while (currentReplyTo && ancestors.length < 40) {
if (visited.has(currentReplyTo)) break;
visited.add(currentReplyTo);
const parent = await collections.ap_timeline.findOne({
$or: [{ uid: currentReplyTo }, { url: currentReplyTo }],
});
if (!parent) break;
ancestors.unshift(parent);
currentReplyTo = parent.inReplyTo;
}
// Find descendants: items that reply to this post's uid or url
const targetUrls = [item.uid, item.url].filter(Boolean);
let descendants = [];
if (targetUrls.length > 0) {
// Get direct replies first
const directReplies = await collections.ap_timeline
.find({ inReplyTo: { $in: targetUrls } })
.sort({ _id: 1 })
.limit(60)
.toArray();
descendants = directReplies;
// Also fetch replies to direct replies (2 levels deep)
if (directReplies.length > 0) {
const replyUrls = directReplies
.flatMap((r) => [r.uid, r.url].filter(Boolean));
const nestedReplies = await collections.ap_timeline
.find({ inReplyTo: { $in: replyUrls } })
.sort({ _id: 1 })
.limit(60)
.toArray();
descendants.push(...nestedReplies);
}
}
// Serialize all items
const emptyInteractions = {
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
};
const serializeOpts = { baseUrl, ...emptyInteractions };
res.json({
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
descendants: descendants.map((d) => serializeStatus(d, serializeOpts)),
});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses ───────────────────────────────────────────────────
// Creates a post via the Micropub pipeline so it goes through the full flow:
// Micropub → content file → Eleventy build → syndication → AP federation.
router.post("/api/v1/statuses", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { application, publication } = req.app.locals;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
const {
status: statusText,
spoiler_text: spoilerText,
visibility = "public",
sensitive = false,
language,
in_reply_to_id: inReplyToId,
media_ids: mediaIds,
} = req.body;
if (!statusText && (!mediaIds || mediaIds.length === 0)) {
return res.status(422).json({ error: "Validation failed: Text content is required" });
}
// Resolve in_reply_to URL from status ID (cursor or ObjectId)
let inReplyTo = null;
if (inReplyToId) {
const replyItem = await findTimelineItemById(collections.ap_timeline, inReplyToId);
if (replyItem) {
inReplyTo = replyItem.uid || replyItem.url;
}
}
// Build JF2 properties for the Micropub pipeline
const jf2 = {
type: "entry",
content: statusText || "",
};
if (inReplyTo) {
jf2["in-reply-to"] = inReplyTo;
}
if (spoilerText) {
jf2.summary = spoilerText;
}
if (sensitive === true || sensitive === "true") {
jf2.sensitive = "true";
}
if (visibility && visibility !== "public") {
jf2.visibility = visibility;
}
if (language) {
jf2["mp-language"] = language;
}
// Syndicate to AP only — posts from Mastodon clients belong to the fediverse.
// Never cross-post to Bluesky (conversations stay in their protocol).
// The publication URL is the AP syndicator's uid.
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
// Create post via Micropub pipeline (same functions the Micropub endpoint uses)
// postData.create() handles: normalization, post type detection, path rendering,
// mp-syndicate-to validated against configured syndicators, MongoDB posts collection
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
const data = await postData.create(application, publication, jf2);
// postContent.create() handles: template rendering, file creation in store
await postContent.create(publication, data);
const postUrl = data.properties.url;
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
// Add to ap_timeline so the post is visible in the Mastodon Client API
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
// Extract hashtags from status text and merge with any Micropub categories
const categories = data.properties.category || [];
const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
if (inlineHashtags) {
const existing = new Set(categories.map((c) => c.toLowerCase()));
for (const match of inlineHashtags) {
const tag = match.trim().slice(1).toLowerCase();
if (!existing.has(tag)) {
existing.add(tag);
categories.push(tag);
}
}
}
// Resolve relative media URLs to absolute
const resolveMedia = (items) => {
if (!items || !items.length) return [];
return items.map((item) => {
if (typeof item === "string") {
return item.startsWith("http") ? item : `${publicationUrl.replace(/\/$/, "")}/${item.replace(/^\//, "")}`;
}
if (item?.url && !item.url.startsWith("http")) {
return { ...item, url: `${publicationUrl.replace(/\/$/, "")}/${item.url.replace(/^\//, "")}` };
}
return item;
});
};
const now = new Date().toISOString();
const timelineItem = await addTimelineItem(collections, {
uid: postUrl,
url: postUrl,
type: data.properties["post-type"] || "note",
content: data.properties.content || { text: statusText || "", html: "" },
summary: spoilerText || "",
sensitive: sensitive === true || sensitive === "true",
visibility: visibility || "public",
language: language || null,
inReplyTo,
published: data.properties.published || now,
createdAt: now,
author: {
name: profile?.name || handle,
url: profile?.url || publicationUrl,
photo: profile?.icon || "",
handle: `@${handle}`,
emojis: [],
bot: false,
},
photo: resolveMedia(data.properties.photo || []),
video: resolveMedia(data.properties.video || []),
audio: resolveMedia(data.properties.audio || []),
category: categories,
counts: { replies: 0, boosts: 0, likes: 0 },
linkPreviews: [],
mentions: [],
emojis: [],
});
// Serialize and return
const serialized = serializeStatus(timelineItem, {
baseUrl,
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
});
res.json(serialized);
} catch (error) {
next(error);
}
});
// ─── DELETE /api/v1/statuses/:id ────────────────────────────────────────────
// Deletes via Micropub pipeline (removes content file + MongoDB post) and
// cleans up the ap_timeline entry.
router.delete("/api/v1/statuses/:id", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { application, publication } = req.app.locals;
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(collections.ap_timeline, id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
// Verify ownership — only allow deleting own posts
const profile = await collections.ap_profile.findOne({});
if (profile && item.author?.url !== profile.url) {
return res.status(403).json({ error: "This action is not allowed" });
}
// Serialize before deleting (Mastodon returns the deleted status with text source)
const serialized = serializeStatus(item, {
baseUrl,
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
});
serialized.text = item.content?.text || "";
// Delete via Micropub pipeline (removes content file from store + MongoDB posts)
const postUrl = item.uid || item.url;
try {
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
const existingPost = await postData.read(application, postUrl);
if (existingPost) {
const deletedData = await postData.delete(application, postUrl);
await postContent.delete(publication, deletedData);
console.info(`[Mastodon API] Deleted post via Micropub: ${postUrl}`);
}
} catch (err) {
// Log but don't block — the post may not exist in Micropub (e.g. old pre-pipeline posts)
console.warn(`[Mastodon API] Micropub delete failed for ${postUrl}: ${err.message}`);
}
// Delete from timeline
await collections.ap_timeline.deleteOne({ _id: objectId });
// Clean up interactions
if (collections.ap_interactions && item.uid) {
await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
}
res.json(serialized);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {
// Stub — we don't track who favourited remotely
res.json([]);
});
// ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
router.get("/api/v1/statuses/:id/reblogged_by", async (req, res) => {
// Stub — we don't track who boosted remotely
res.json([]);
});
// ─── POST /api/v1/statuses/:id/favourite ────────────────────────────────────
router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
const opts = getFederationOpts(req);
await likePost({
targetUrl: item.uid || item.url,
...opts,
interactions: collections.ap_interactions,
});
const interactionState = await loadItemInteractions(collections, item);
// Force favourited=true since we just liked it
interactionState.favouritedIds.add(item.uid);
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses/:id/unfavourite ──────────────────────────────────
router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
const opts = getFederationOpts(req);
await unlikePost({
targetUrl: item.uid || item.url,
...opts,
interactions: collections.ap_interactions,
});
const interactionState = await loadItemInteractions(collections, item);
interactionState.favouritedIds.delete(item.uid);
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses/:id/reblog ───────────────────────────────────────
router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
const opts = getFederationOpts(req);
await boostPost({
targetUrl: item.uid || item.url,
...opts,
interactions: collections.ap_interactions,
});
const interactionState = await loadItemInteractions(collections, item);
interactionState.rebloggedIds.add(item.uid);
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses/:id/unreblog ─────────────────────────────────────
router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
const opts = getFederationOpts(req);
await unboostPost({
targetUrl: item.uid || item.url,
...opts,
interactions: collections.ap_interactions,
});
const interactionState = await loadItemInteractions(collections, item);
interactionState.rebloggedIds.delete(item.uid);
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses/:id/bookmark ─────────────────────────────────────
router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
await bookmarkPost({
targetUrl: item.uid || item.url,
interactions: collections.ap_interactions,
});
const interactionState = await loadItemInteractions(collections, item);
interactionState.bookmarkedIds.add(item.uid);
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses/:id/unbookmark ───────────────────────────────────
router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
await unbookmarkPost({
targetUrl: item.uid || item.url,
interactions: collections.ap_interactions,
});
const interactionState = await loadItemInteractions(collections, item);
interactionState.bookmarkedIds.delete(item.uid);
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
} catch (error) {
next(error);
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Find a timeline item by cursor ID (published-based) or ObjectId (legacy).
* Status IDs are now encodeCursor(published) — milliseconds since epoch.
* Falls back to ObjectId lookup for backwards compatibility.
*
* @param {object} collection - ap_timeline collection
* @param {string} id - Status ID from client
* @returns {Promise<object|null>} Timeline document or null
*/
async function findTimelineItemById(collection, id) {
// Try cursor-based lookup first (published date from ms-since-epoch)
const publishedDate = decodeCursor(id);
if (publishedDate) {
const item = await collection.findOne({ published: publishedDate });
if (item) return item;
}
// Fall back to ObjectId lookup (legacy IDs)
try {
return await collection.findOne({ _id: new ObjectId(id) });
} catch {
return null;
}
}
/**
* Resolve a timeline item from the :id param, plus common context.
*/
async function resolveStatusForInteraction(req) {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(collections.ap_timeline, req.params.id);
return { item, collections, baseUrl };
}
/**
* Build federation options from request context for interaction helpers.
*/
function getFederationOpts(req) {
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
return {
federation: pluginOptions.federation,
handle: pluginOptions.handle || "user",
publicationUrl: pluginOptions.publicationUrl,
collections: req.app.locals.mastodonCollections,
};
}
async function loadItemInteractions(collections, item) {
const favouritedIds = new Set();
const rebloggedIds = new Set();
const bookmarkedIds = new Set();
if (!collections.ap_interactions || !item.uid) {
return { favouritedIds, rebloggedIds, bookmarkedIds };
}
const lookupUrls = [item.uid, item.url].filter(Boolean);
const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: lookupUrls } })
.toArray();
for (const i of interactions) {
const uid = item.uid;
if (i.type === "like") favouritedIds.add(uid);
else if (i.type === "boost") rebloggedIds.add(uid);
else if (i.type === "bookmark") bookmarkedIds.add(uid);
}
return { favouritedIds, rebloggedIds, bookmarkedIds };
}
export default router;

View File

@@ -0,0 +1,380 @@
/**
* Stub and lightweight endpoints for Mastodon Client API.
*
* Some endpoints have real implementations (markers, bookmarks, favourites).
* Others return empty/minimal responses to prevent client errors.
*
* Phanpy calls these on startup, navigation, and various page loads:
* - markers (BackgroundService, every page load)
* - follow_requests (home + notifications pages)
* - announcements (notifications page)
* - custom_emojis (compose screen)
* - filters (status rendering)
* - lists (sidebar navigation)
* - mutes, blocks (nav menu)
* - featured_tags (profile view)
* - bookmarks, favourites (dedicated pages)
* - trends (explore page)
* - followed_tags (followed tags page)
* - suggestions (explore page)
*/
import express from "express";
import { serializeStatus } from "../entities/status.js";
import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── Markers ────────────────────────────────────────────────────────────────
router.get("/api/v1/markers", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const timelines = [].concat(req.query["timeline[]"] || req.query.timeline || []);
if (!timelines.length || !collections.ap_markers) {
return res.json({});
}
const docs = await collections.ap_markers
.find({ timeline: { $in: timelines } })
.toArray();
const result = {};
for (const doc of docs) {
result[doc.timeline] = {
last_read_id: doc.last_read_id,
version: doc.version || 0,
updated_at: doc.updated_at || new Date().toISOString(),
};
}
res.json(result);
} catch (error) {
next(error);
}
});
router.post("/api/v1/markers", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
if (!collections.ap_markers) {
return res.json({});
}
const result = {};
for (const timeline of ["home", "notifications"]) {
const data = req.body[timeline];
if (!data?.last_read_id) continue;
const now = new Date().toISOString();
await collections.ap_markers.updateOne(
{ timeline },
{
$set: { last_read_id: data.last_read_id, updated_at: now },
$inc: { version: 1 },
$setOnInsert: { timeline },
},
{ upsert: true },
);
const doc = await collections.ap_markers.findOne({ timeline });
result[timeline] = {
last_read_id: doc.last_read_id,
version: doc.version || 0,
updated_at: doc.updated_at || now,
};
}
res.json(result);
} catch (error) {
next(error);
}
});
// ─── Follow requests ────────────────────────────────────────────────────────
router.get("/api/v1/follow_requests", (req, res) => {
res.json([]);
});
// ─── Announcements ──────────────────────────────────────────────────────────
router.get("/api/v1/announcements", (req, res) => {
res.json([]);
});
// ─── Custom emojis ──────────────────────────────────────────────────────────
router.get("/api/v1/custom_emojis", (req, res) => {
res.json([]);
});
// ─── Filters (v2) ───────────────────────────────────────────────────────────
router.get("/api/v2/filters", (req, res) => {
res.json([]);
});
router.get("/api/v1/filters", (req, res) => {
res.json([]);
});
// ─── Lists ──────────────────────────────────────────────────────────────────
router.get("/api/v1/lists", (req, res) => {
res.json([]);
});
// ─── Mutes ──────────────────────────────────────────────────────────────────
router.get("/api/v1/mutes", (req, res) => {
res.json([]);
});
// ─── Blocks ─────────────────────────────────────────────────────────────────
router.get("/api/v1/blocks", (req, res) => {
res.json([]);
});
// ─── Bookmarks ──────────────────────────────────────────────────────────────
router.get("/api/v1/bookmarks", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
if (!collections.ap_interactions) {
return res.json([]);
}
const baseFilter = { type: "bookmark" };
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
let interactions = await collections.ap_interactions
.find(filter)
.sort(sort)
.limit(limit)
.toArray();
if (reverse) interactions.reverse();
// Batch-fetch the actual timeline items
const objectUrls = interactions.map((i) => i.objectUrl).filter(Boolean);
if (!objectUrls.length) {
return res.json([]);
}
const items = await collections.ap_timeline
.find({ $or: [{ uid: { $in: objectUrls } }, { url: { $in: objectUrls } }] })
.toArray();
const itemMap = new Map();
for (const item of items) {
if (item.uid) itemMap.set(item.uid, item);
if (item.url) itemMap.set(item.url, item);
}
const statuses = [];
for (const interaction of interactions) {
const item = itemMap.get(interaction.objectUrl);
if (item) {
statuses.push(
serializeStatus(item, {
baseUrl,
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set([item.uid]),
pinnedIds: new Set(),
}),
);
}
}
setPaginationHeaders(res, req, interactions, limit);
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── Favourites ─────────────────────────────────────────────────────────────
router.get("/api/v1/favourites", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
if (!collections.ap_interactions) {
return res.json([]);
}
const baseFilter = { type: "like" };
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
let interactions = await collections.ap_interactions
.find(filter)
.sort(sort)
.limit(limit)
.toArray();
if (reverse) interactions.reverse();
const objectUrls = interactions.map((i) => i.objectUrl).filter(Boolean);
if (!objectUrls.length) {
return res.json([]);
}
const items = await collections.ap_timeline
.find({ $or: [{ uid: { $in: objectUrls } }, { url: { $in: objectUrls } }] })
.toArray();
const itemMap = new Map();
for (const item of items) {
if (item.uid) itemMap.set(item.uid, item);
if (item.url) itemMap.set(item.url, item);
}
const statuses = [];
for (const interaction of interactions) {
const item = itemMap.get(interaction.objectUrl);
if (item) {
statuses.push(
serializeStatus(item, {
baseUrl,
favouritedIds: new Set([item.uid]),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
}),
);
}
}
setPaginationHeaders(res, req, interactions, limit);
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── Featured tags ──────────────────────────────────────────────────────────
router.get("/api/v1/featured_tags", (req, res) => {
res.json([]);
});
// ─── Followed tags ──────────────────────────────────────────────────────────
router.get("/api/v1/followed_tags", (req, res) => {
res.json([]);
});
// ─── Suggestions ────────────────────────────────────────────────────────────
router.get("/api/v2/suggestions", (req, res) => {
res.json([]);
});
// ─── Trends ─────────────────────────────────────────────────────────────────
router.get("/api/v1/trends/statuses", (req, res) => {
res.json([]);
});
router.get("/api/v1/trends/tags", (req, res) => {
res.json([]);
});
router.get("/api/v1/trends/links", (req, res) => {
res.json([]);
});
// ─── Scheduled statuses ─────────────────────────────────────────────────────
router.get("/api/v1/scheduled_statuses", (req, res) => {
res.json([]);
});
// ─── Conversations ──────────────────────────────────────────────────────────
router.get("/api/v1/conversations", (req, res) => {
res.json([]);
});
// ─── Domain blocks ──────────────────────────────────────────────────────────
router.get("/api/v1/domain_blocks", (req, res) => {
res.json([]);
});
// ─── Endorsements ───────────────────────────────────────────────────────────
router.get("/api/v1/endorsements", (req, res) => {
res.json([]);
});
// ─── Account statuses ───────────────────────────────────────────────────────
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
// Try to find the profile to see if this is the local user
const profile = await collections.ap_profile.findOne({});
const isLocal = profile && profile._id.toString() === req.params.id;
if (isLocal && profile?.url) {
// Return statuses authored by local user
const { serializeStatus } = await import("../entities/status.js");
const { parseLimit } = await import("../helpers/pagination.js");
const limit = parseLimit(req.query.limit);
const items = await collections.ap_timeline
.find({ "author.url": profile.url, isContext: { $ne: true } })
.sort({ _id: -1 })
.limit(limit)
.toArray();
const statuses = items.map((item) =>
serializeStatus(item, {
baseUrl,
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
}),
);
return res.json(statuses);
}
// Remote account or unknown — return empty
res.json([]);
} catch (error) {
next(error);
}
});
// ─── Account followers/following ────────────────────────────────────────────
router.get("/api/v1/accounts/:id/followers", (req, res) => {
res.json([]);
});
router.get("/api/v1/accounts/:id/following", (req, res) => {
res.json([]);
});
export default router;

View File

@@ -0,0 +1,281 @@
/**
* Timeline endpoints for Mastodon Client API.
*
* GET /api/v1/timelines/home — home timeline (authenticated)
* GET /api/v1/timelines/public — public/federated timeline
* GET /api/v1/timelines/tag/:hashtag — hashtag timeline
*/
import express from "express";
import { serializeStatus } from "../entities/status.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/timelines/home ─────────────────────────────────────────────
router.get("/api/v1/timelines/home", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
// Base filter: exclude context-only items and private/direct posts
const baseFilter = {
isContext: { $ne: true },
visibility: { $nin: ["direct"] },
};
// Apply cursor-based pagination
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
// Fetch items from timeline
let items = await collections.ap_timeline
.find(filter)
.sort(sort)
.limit(limit)
.toArray();
// Reverse if min_id was used (ascending sort → need descending order)
if (reverse) {
items.reverse();
}
// Apply mute/block filtering
const modCollections = {
ap_muted: collections.ap_muted,
ap_blocked: collections.ap_blocked,
ap_profile: collections.ap_profile,
};
const moderation = await loadModerationData(modCollections);
items = applyModerationFilters(items, moderation);
// Load interaction state (likes, boosts, bookmarks) for the authenticated user
const { favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState(
collections,
items,
);
// Serialize to Mastodon Status entities
const statuses = items.map((item) =>
serializeStatus(item, {
baseUrl,
favouritedIds,
rebloggedIds,
bookmarkedIds,
pinnedIds: new Set(),
}),
);
// Set pagination Link headers
setPaginationHeaders(res, req, items, limit);
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/timelines/public ───────────────────────────────────────────
router.get("/api/v1/timelines/public", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
// Public timeline: only public visibility, no context items
const baseFilter = {
isContext: { $ne: true },
visibility: "public",
};
// Only original posts (exclude boosts from public timeline unless local=true)
if (req.query.only_media === "true") {
baseFilter.$or = [
{ "photo.0": { $exists: true } },
{ "video.0": { $exists: true } },
{ "audio.0": { $exists: true } },
];
}
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
let items = await collections.ap_timeline
.find(filter)
.sort(sort)
.limit(limit)
.toArray();
if (reverse) {
items.reverse();
}
// Apply mute/block filtering
const modCollections = {
ap_muted: collections.ap_muted,
ap_blocked: collections.ap_blocked,
ap_profile: collections.ap_profile,
};
const moderation = await loadModerationData(modCollections);
items = applyModerationFilters(items, moderation);
// Load interaction state if authenticated
let favouritedIds = new Set();
let rebloggedIds = new Set();
let bookmarkedIds = new Set();
if (req.mastodonToken) {
({ favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState(
collections,
items,
));
}
const statuses = items.map((item) =>
serializeStatus(item, {
baseUrl,
favouritedIds,
rebloggedIds,
bookmarkedIds,
pinnedIds: new Set(),
}),
);
setPaginationHeaders(res, req, items, limit);
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/timelines/tag/:hashtag ─────────────────────────────────────
router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
const hashtag = req.params.hashtag;
const baseFilter = {
isContext: { $ne: true },
visibility: { $in: ["public", "unlisted"] },
category: hashtag,
};
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
let items = await collections.ap_timeline
.find(filter)
.sort(sort)
.limit(limit)
.toArray();
if (reverse) {
items.reverse();
}
// Load interaction state if authenticated
let favouritedIds = new Set();
let rebloggedIds = new Set();
let bookmarkedIds = new Set();
if (req.mastodonToken) {
({ favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState(
collections,
items,
));
}
const statuses = items.map((item) =>
serializeStatus(item, {
baseUrl,
favouritedIds,
rebloggedIds,
bookmarkedIds,
pinnedIds: new Set(),
}),
);
setPaginationHeaders(res, req, items, limit);
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Load interaction state (favourited, reblogged, bookmarked) for a set of timeline items.
*
* Queries ap_interactions for likes and boosts matching the items' UIDs.
*
* @param {object} collections - MongoDB collections
* @param {Array} items - Timeline items
* @returns {Promise<{ favouritedIds: Set<string>, rebloggedIds: Set<string>, bookmarkedIds: Set<string> }>}
*/
async function loadInteractionState(collections, items) {
const favouritedIds = new Set();
const rebloggedIds = new Set();
const bookmarkedIds = new Set();
if (!items.length || !collections.ap_interactions) {
return { favouritedIds, rebloggedIds, bookmarkedIds };
}
// Collect all UIDs and URLs to look up
const lookupUrls = new Set();
const urlToUid = new Map();
for (const item of items) {
if (item.uid) {
lookupUrls.add(item.uid);
urlToUid.set(item.uid, item.uid);
}
if (item.url && item.url !== item.uid) {
lookupUrls.add(item.url);
urlToUid.set(item.url, item.uid || item.url);
}
}
if (lookupUrls.size === 0) {
return { favouritedIds, rebloggedIds, bookmarkedIds };
}
const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: [...lookupUrls] } })
.toArray();
for (const interaction of interactions) {
const uid = urlToUid.get(interaction.objectUrl) || interaction.objectUrl;
if (interaction.type === "like") {
favouritedIds.add(uid);
} else if (interaction.type === "boost") {
rebloggedIds.add(uid);
} else if (interaction.type === "bookmark") {
bookmarkedIds.add(uid);
}
}
return { favouritedIds, rebloggedIds, bookmarkedIds };
}
export default router;

358
locales/de.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Follower",
"following": "Folge ich",
"activities": "Aktivitätsprotokoll",
"featured": "Angeheftete Beiträge",
"featuredTags": "Vorgestellte Tags",
"recentActivity": "Neueste Aktivität",
"noActivity": "Noch keine Aktivität. Sobald dein Akteur föderiert ist, werden Interaktionen hier angezeigt.",
"noFollowers": "Noch keine Follower.",
"noFollowing": "Folgt noch niemandem.",
"pendingFollows": "Ausstehend",
"noPendingFollows": "Keine ausstehenden Folgeanfragen.",
"approve": "Genehmigen",
"reject": "Ablehnen",
"followApproved": "Folgeanfrage genehmigt.",
"followRejected": "Folgeanfrage abgelehnt.",
"followRequest": "möchte dir folgen",
"followerCount": "%d Follower",
"followerCount_plural": "%d Follower",
"followingCount": "%d folge ich",
"followedAt": "Gefolgt seit",
"source": "Quelle",
"sourceImport": "Mastodon-Import",
"sourceManual": "Manuell",
"sourceFederation": "Föderation",
"sourceRefollowPending": "Erneutes Folgen ausstehend",
"sourceRefollowFailed": "Erneutes Folgen fehlgeschlagen",
"direction": "Richtung",
"directionInbound": "Empfangen",
"directionOutbound": "Gesendet",
"profile": {
"title": "Profil",
"intro": "Bearbeite, wie dein Akteur anderen Nutzern im Fediverse angezeigt wird. Änderungen werden sofort wirksam.",
"nameLabel": "Anzeigename",
"nameHint": "Dein Name, wie er auf deinem Fediverse-Profil angezeigt wird",
"summaryLabel": "Biografie",
"summaryHint": "Eine kurze Beschreibung über dich. HTML ist erlaubt.",
"urlLabel": "Website-URL",
"urlHint": "Deine Webadresse, als Link auf deinem Profil angezeigt",
"iconLabel": "Avatar-URL",
"iconHint": "URL zu deinem Profilbild (quadratisch, mindestens 400x400px empfohlen)",
"imageLabel": "Header-Bild-URL",
"imageHint": "URL zu einem Bannerbild, das oben auf deinem Profil angezeigt wird",
"manualApprovalLabel": "Follower manuell genehmigen",
"manualApprovalHint": "Wenn aktiviert, müssen Folgeanfragen erst genehmigt werden, bevor sie wirksam werden",
"actorTypeLabel": "Akteur-Typ",
"actorTypeHint": "Wie dein Konto im Fediverse erscheint. Person für Einzelpersonen, Service für Bots oder automatisierte Konten, Organization für Gruppen oder Unternehmen.",
"linksLabel": "Profil-Links",
"linksHint": "Links auf deinem Fediverse-Profil. Füge deine Website, soziale Konten oder andere URLs hinzu. Seiten, die mit rel=\"me\" zurückverlinken, werden auf Mastodon als verifiziert angezeigt.",
"linkNameLabel": "Bezeichnung",
"linkValueLabel": "URL",
"addLink": "Link hinzufügen",
"removeLink": "Entfernen",
"authorizedFetchLabel": "Autorisiertes Abrufen erfordern (sicherer Modus)",
"authorizedFetchHint": "Wenn aktiviert, können nur Server mit gültigen HTTP-Signaturen deinen Akteur und Sammlungen abrufen. Dies verbessert die Privatsphäre, kann aber die Kompatibilität mit einigen Clients einschränken.",
"save": "Profil speichern",
"saved": "Profil gespeichert. Änderungen sind jetzt im Fediverse sichtbar.",
"public": {
"followPrompt": "Folge mir im Fediverse",
"copyHandle": "Handle kopieren",
"copied": "Kopiert!",
"pinnedPosts": "Angeheftete Beiträge",
"recentPosts": "Neueste Beiträge",
"joinedDate": "Beigetreten",
"posts": "Beiträge",
"followers": "Follower",
"following": "Folge ich",
"viewOnSite": "Auf der Website anzeigen"
},
"remote": {
"follow": "Folgen",
"unfollow": "Entfolgen",
"viewOn": "Ansehen auf",
"postsTitle": "Beiträge",
"noPosts": "Noch keine Beiträge von diesem Konto.",
"followToSee": "Folge diesem Konto, um dessen Beiträge in deiner Zeitleiste zu sehen.",
"notFound": "Dieses Konto konnte nicht gefunden werden. Es wurde möglicherweise gelöscht oder der Server ist nicht erreichbar."
}
},
"migrate": {
"title": "Mastodon-Migration",
"intro": "Diese Anleitung führt dich durch den Umzug deiner Mastodon-Identität auf deine IndieWeb-Website. Führe jeden Schritt der Reihe nach aus — deine bestehenden Follower werden benachrichtigt und können dir automatisch erneut folgen.",
"step1Title": "Schritt 1 — Altes Konto verknüpfen",
"step1Desc": "Teile dem Fediverse mit, dass dein altes Mastodon-Konto und diese Website derselben Person gehören. Dies setzt die <code>alsoKnownAs</code>-Eigenschaft auf deinem ActivityPub-Akteur, die Mastodon überprüft, bevor ein Umzug erlaubt wird.",
"aliasLabel": "URL des alten Mastodon-Kontos",
"aliasHint": "Die vollständige URL deines Mastodon-Profils, z.B. https://mstdn.social/users/rmdes",
"aliasSave": "Alias speichern",
"aliasCurrent": "Aktueller Alias",
"aliasNone": "Noch kein Alias konfiguriert.",
"step2Title": "Schritt 2 — Soziales Netzwerk importieren",
"step2Desc": "Lade die CSV-Dateien aus deinem Mastodon-Datenexport hoch, um deine Verbindungen zu übernehmen. Gehe zu deiner Mastodon-Instanz → Einstellungen → Import und Export → Datenexport, um sie herunterzuladen.",
"importLegend": "Was importieren",
"fileLabel": "CSV-Datei",
"fileHint": "Wähle eine CSV-Datei aus deinem Mastodon-Datenexport (z.B. following_accounts.csv oder followers.csv)",
"importButton": "Importieren",
"importFollowing": "Folge-Liste",
"importFollowingHint": "Konten, denen du folgst — sie erscheinen sofort in deiner Folge-Liste",
"importFollowers": "Follower-Liste",
"importFollowersHint": "Deine aktuellen Follower — sie werden als ausstehend erfasst, bis sie dir nach dem Umzug in Schritt 3 erneut folgen",
"step3Title": "Schritt 3 — Konto umziehen",
"step3Desc": "Sobald du deinen Alias gespeichert und deine Daten importiert hast, gehe zu deiner Mastodon-Instanz → Einstellungen → Konto → <strong>Zu einem anderen Konto umziehen</strong>. Gib dein neues Fediverse-Handle ein und bestätige. Mastodon wird alle deine Follower benachrichtigen, und die, deren Server es unterstützen, werden dir hier automatisch erneut folgen. Dieser Schritt ist unwiderruflich — dein altes Konto wird zu einer Weiterleitung.",
"errorNoFile": "Bitte wähle eine CSV-Datei vor dem Import aus.",
"success": "%d Folge-ich, %d Follower importiert (%d fehlgeschlagen).",
"failedList": "Konnte nicht aufgelöst werden: %s",
"failedListSummary": "Fehlgeschlagene Handles",
"aliasSuccess": "Alias gespeichert — dein Akteur-Dokument enthält jetzt dieses Konto als alsoKnownAs."
},
"refollow": {
"title": "Stapel-Neufolgen",
"progress": "Neufolgen-Fortschritt",
"remaining": "Verbleibend",
"awaitingAccept": "Warte auf Bestätigung",
"accepted": "Bestätigt",
"failed": "Fehlgeschlagen",
"pause": "Pausieren",
"resume": "Fortsetzen",
"status": {
"idle": "Inaktiv",
"running": "Läuft",
"paused": "Pausiert",
"completed": "Abgeschlossen"
}
},
"moderation": {
"title": "Moderation",
"blockedTitle": "Blockierte Konten",
"mutedActorsTitle": "Stummgeschaltete Konten",
"mutedKeywordsTitle": "Stummgeschaltete Schlüsselwörter",
"noBlocked": "Keine blockierten Konten.",
"noMutedActors": "Keine stummgeschalteten Konten.",
"noMutedKeywords": "Keine stummgeschalteten Schlüsselwörter.",
"unblock": "Entblocken",
"unmute": "Stummschaltung aufheben",
"addKeywordTitle": "Stummgeschaltetes Schlüsselwort hinzufügen",
"keywordPlaceholder": "Schlüsselwort oder Phrase eingeben…",
"addKeyword": "Hinzufügen",
"muteActor": "Stummschalten",
"blockActor": "Blockieren",
"filterModeTitle": "Filtermodus",
"filterModeHint": "Wähle, wie stummgeschaltete Inhalte in deiner Zeitleiste behandelt werden. Blockierte Konten werden immer ausgeblendet.",
"filterModeHide": "Ausblenden — aus der Zeitleiste entfernen",
"filterModeWarn": "Warnen — hinter Inhaltswarnung anzeigen",
"cwMutedAccount": "Stummgeschaltetes Konto",
"cwMutedKeyword": "Stummgeschaltetes Schlüsselwort:",
"cwFiltered": "Gefilterter Inhalt"
},
"compose": {
"title": "Antwort verfassen",
"placeholder": "Schreibe deine Antwort…",
"syndicateLabel": "Syndizieren an",
"submitMicropub": "Antwort veröffentlichen",
"cancel": "Abbrechen",
"errorEmpty": "Antwortinhalt darf nicht leer sein",
"visibilityLabel": "Sichtbarkeit",
"visibilityPublic": "Öffentlich",
"visibilityUnlisted": "Nicht gelistet",
"visibilityFollowers": "Nur Follower",
"cwLabel": "Inhaltswarnung",
"cwPlaceholder": "Schreibe deine Warnung hier…"
},
"notifications": {
"title": "Benachrichtigungen",
"empty": "Noch keine Benachrichtigungen. Interaktionen von anderen Fediverse-Nutzern werden hier angezeigt.",
"liked": "hat deinen Beitrag geliked",
"boostedPost": "hat deinen Beitrag geboostet",
"followedYou": "folgt dir",
"repliedTo": "hat auf deinen Beitrag geantwortet",
"mentionedYou": "hat dich erwähnt",
"markAllRead": "Alle als gelesen markieren",
"clearAll": "Alle löschen",
"clearConfirm": "Alle Benachrichtigungen löschen? Dies kann nicht rückgängig gemacht werden.",
"dismiss": "Verwerfen",
"viewThread": "Thread anzeigen",
"tabs": {
"all": "Alle",
"replies": "Antworten",
"likes": "Likes",
"boosts": "Boosts",
"follows": "Folgt",
"dms": "DMs",
"reports": "Meldungen"
},
"emptyTab": "Noch keine %s-Benachrichtigungen."
},
"messages": {
"title": "Nachrichten",
"empty": "Noch keine Nachrichten. Direktnachrichten von anderen Fediverse-Nutzern werden hier angezeigt.",
"allConversations": "Alle Unterhaltungen",
"compose": "Neue Nachricht",
"send": "Nachricht senden",
"delete": "Löschen",
"markAllRead": "Alle als gelesen markieren",
"clearAll": "Alle löschen",
"clearConfirm": "Alle Nachrichten löschen? Dies kann nicht rückgängig gemacht werden.",
"recipientLabel": "An",
"recipientPlaceholder": "@benutzer@instanz.social",
"placeholder": "Schreibe deine Nachricht...",
"sentTo": "An",
"replyingTo": "Antwort an",
"sentYouDM": "hat dir eine Direktnachricht gesendet",
"viewMessage": "Nachricht anzeigen",
"errorEmpty": "Nachrichteninhalt darf nicht leer sein.",
"errorNoRecipient": "Bitte gib einen Empfänger ein.",
"errorRecipientNotFound": "Dieser Benutzer konnte nicht gefunden werden. Versuche ein vollständiges @benutzer@domain-Handle."
},
"reader": {
"title": "Leser",
"tabs": {
"all": "Alle",
"notes": "Notizen",
"articles": "Artikel",
"replies": "Antworten",
"boosts": "Boosts",
"media": "Medien"
},
"empty": "Deine Zeitleiste ist leer. Folge einigen Konten, um deren Beiträge hier zu sehen.",
"boosted": "geboostet",
"replyingTo": "Antwort an",
"showContent": "Inhalt anzeigen",
"hideContent": "Inhalt ausblenden",
"sensitiveContent": "Sensibler Inhalt",
"videoNotSupported": "Dein Browser unterstützt das Video-Element nicht.",
"audioNotSupported": "Dein Browser unterstützt das Audio-Element nicht.",
"actions": {
"reply": "Antworten",
"boost": "Boosten",
"unboost": "Boost rückgängig",
"like": "Liken",
"unlike": "Like rückgängig",
"viewOriginal": "Original anzeigen",
"liked": "Geliked",
"boosted": "Geboostet",
"likeError": "Dieser Beitrag konnte nicht geliked werden",
"boostError": "Dieser Beitrag konnte nicht geboostet werden"
},
"post": {
"title": "Beitragsdetails",
"notFound": "Beitrag nicht gefunden oder nicht mehr verfügbar.",
"openExternal": "Auf der Original-Instanz öffnen",
"parentPosts": "Thread",
"replies": "Antworten",
"back": "Zurück zur Zeitleiste",
"loadingThread": "Thread wird geladen...",
"threadError": "Vollständiger Thread konnte nicht geladen werden"
},
"resolve": {
"placeholder": "Füge eine Fediverse-URL oder ein @benutzer@domain-Handle ein…",
"label": "Fediverse-Beitrag oder -Konto nachschlagen",
"button": "Nachschlagen",
"notFoundTitle": "Nicht gefunden",
"notFound": "Dieser Beitrag oder dieses Konto konnte nicht gefunden werden. Die URL ist möglicherweise ungültig, der Server nicht erreichbar oder der Inhalt wurde gelöscht.",
"followersLabel": "Follower"
},
"linkPreview": {
"label": "Linkvorschau"
},
"explore": {
"title": "Entdecken",
"description": "Durchsuche öffentliche Zeitleisten von entfernten Mastodon-kompatiblen Instanzen.",
"instancePlaceholder": "Gib einen Instanz-Hostnamen ein, z.B. mastodon.social",
"browse": "Durchsuchen",
"local": "Lokal",
"federated": "Föderiert",
"loadError": "Zeitleiste von dieser Instanz konnte nicht geladen werden. Sie ist möglicherweise nicht erreichbar oder unterstützt die Mastodon-API nicht.",
"timeout": "Zeitüberschreitung. Die Instanz ist möglicherweise langsam oder nicht erreichbar.",
"noResults": "Keine Beiträge auf der öffentlichen Zeitleiste dieser Instanz gefunden.",
"invalidInstance": "Ungültiger Instanz-Hostname. Bitte gib einen gültigen Domainnamen ein.",
"mauLabel": "MAU",
"timelineSupported": "Öffentliche Zeitleiste verfügbar",
"timelineUnsupported": "Öffentliche Zeitleiste nicht verfügbar",
"hashtagLabel": "Hashtag (optional)",
"hashtagPlaceholder": "z.B. indieweb",
"hashtagHint": "Ergebnisse nach einem bestimmten Hashtag filtern",
"tabs": {
"label": "Entdecken-Tabs",
"search": "Suchen",
"pinAsTab": "Als Tab anheften",
"pinned": "Angeheftet",
"remove": "Tab entfernen",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"addHashtag": "Hashtag-Tab hinzufügen",
"hashtagTabPlaceholder": "Hashtag eingeben",
"addTab": "Hinzufügen",
"retry": "Erneut versuchen",
"noInstances": "Hefte zuerst einige Instanzen an, um Hashtag-Tabs zu verwenden.",
"sources": "Suche #%s auf %d Instanz",
"sources_plural": "Suche #%s auf %d Instanzen",
"sourcesPartial": "%d von %d Instanzen haben geantwortet"
}
},
"tagTimeline": {
"postsTagged": "%d Beitrag",
"postsTagged_plural": "%d Beiträge",
"noPosts": "Keine Beiträge mit #%s in deiner Zeitleiste gefunden.",
"followTag": "Hashtag folgen",
"unfollowTag": "Hashtag entfolgen",
"following": "Folge ich"
},
"pagination": {
"newer": "← Neuere",
"older": "Ältere →",
"loadMore": "Mehr laden",
"loading": "Lädt…",
"noMore": "Du bist auf dem neuesten Stand."
}
},
"myProfile": {
"title": "Mein Profil",
"posts": "Beiträge",
"editProfile": "Profil bearbeiten",
"empty": "Hier ist noch nichts.",
"tabs": {
"posts": "Beiträge",
"replies": "Antworten",
"likes": "Likes",
"boosts": "Boosts"
}
},
"poll": {
"voters": "Abstimmende",
"votes": "Stimmen",
"closed": "Abstimmung beendet",
"endsAt": "Endet"
},
"federation": {
"deleteSuccess": "Löschaktivität an Follower gesendet",
"deleteButton": "Aus dem Fediverse löschen"
},
"federationMgmt": {
"title": "Föderation",
"collections": "Sammlungszustand",
"quickActions": "Schnellaktionen",
"broadcastActor": "Akteur-Update senden",
"debugDashboard": "Debug-Dashboard",
"objectLookup": "Objekt-Nachschlag",
"lookupPlaceholder": "URL oder @benutzer@domain-Handle…",
"lookup": "Nachschlagen",
"lookupLoading": "Wird aufgelöst…",
"postActions": "Beitrags-Föderation",
"viewJson": "JSON",
"rebroadcast": "Create-Aktivität erneut senden",
"rebroadcastShort": "Erneut senden",
"broadcastDelete": "Delete-Aktivität senden",
"deleteShort": "Löschen",
"noPosts": "Keine Beiträge gefunden.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Neueste Aktivität",
"viewAllActivities": "Alle Aktivitäten anzeigen →"
},
"reports": {
"sentReport": "hat eine Meldung eingereicht",
"title": "Meldungen"
}
}
}

358
locales/es-419.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Seguidores",
"following": "Siguiendo",
"activities": "Registro de actividad",
"featured": "Publicaciones fijadas",
"featuredTags": "Etiquetas destacadas",
"recentActivity": "Actividad reciente",
"noActivity": "Aún no hay actividad. Una vez que tu actor esté federado, las interacciones aparecerán aquí.",
"noFollowers": "Aún no hay seguidores.",
"noFollowing": "Aún no sigues a nadie.",
"pendingFollows": "Pendientes",
"noPendingFollows": "No hay solicitudes de seguimiento pendientes.",
"approve": "Aprobar",
"reject": "Rechazar",
"followApproved": "Solicitud de seguimiento aprobada.",
"followRejected": "Solicitud de seguimiento rechazada.",
"followRequest": "solicitó seguirte",
"followerCount": "%d seguidor",
"followerCount_plural": "%d seguidores",
"followingCount": "%d siguiendo",
"followedAt": "Siguiendo desde",
"source": "Origen",
"sourceImport": "Importación de Mastodon",
"sourceManual": "Manual",
"sourceFederation": "Federación",
"sourceRefollowPending": "Re-seguimiento pendiente",
"sourceRefollowFailed": "Re-seguimiento fallido",
"direction": "Dirección",
"directionInbound": "Recibido",
"directionOutbound": "Enviado",
"profile": {
"title": "Perfil",
"intro": "Edita cómo tu actor aparece ante otros usuarios del fediverse. Los cambios se aplican de inmediato.",
"nameLabel": "Nombre para mostrar",
"nameHint": "Tu nombre tal como se muestra en tu perfil del fediverse",
"summaryLabel": "Biografía",
"summaryHint": "Una breve descripción sobre ti. Se permite HTML.",
"urlLabel": "URL del sitio web",
"urlHint": "La dirección de tu sitio web, mostrada como enlace en tu perfil",
"iconLabel": "URL del avatar",
"iconHint": "URL de tu imagen de perfil (cuadrada, se recomienda al menos 400x400px)",
"imageLabel": "URL de la imagen de encabezado",
"imageHint": "URL de una imagen de banner mostrada en la parte superior de tu perfil",
"manualApprovalLabel": "Aprobar seguidores manualmente",
"manualApprovalHint": "Cuando está habilitado, las solicitudes de seguimiento requieren tu aprobación antes de que se apliquen",
"actorTypeLabel": "Tipo de actor",
"actorTypeHint": "Cómo aparece tu cuenta en el fediverse. Person para individuos, Service para bots o cuentas automatizadas, Organization para grupos o empresas.",
"linksLabel": "Enlaces del perfil",
"linksHint": "Enlaces mostrados en tu perfil del fediverse. Agrega tu sitio web, cuentas sociales u otras URLs. Las páginas que enlacen con rel=\"me\" se mostrarán como verificadas en Mastodon.",
"linkNameLabel": "Etiqueta",
"linkValueLabel": "URL",
"addLink": "Agregar enlace",
"removeLink": "Eliminar",
"authorizedFetchLabel": "Requerir obtención autorizada (modo seguro)",
"authorizedFetchHint": "Cuando está habilitado, solo los servidores con firmas HTTP válidas pueden obtener tu actor y colecciones. Esto mejora la privacidad pero puede reducir la compatibilidad con algunos clientes.",
"save": "Guardar perfil",
"saved": "Perfil guardado exitosamente. Los cambios son ahora visibles en el fediverse.",
"public": {
"followPrompt": "Sígueme en el fediverse",
"copyHandle": "Copiar identificador",
"copied": "¡Copiado!",
"pinnedPosts": "Publicaciones fijadas",
"recentPosts": "Publicaciones recientes",
"joinedDate": "Se unió",
"posts": "Publicaciones",
"followers": "Seguidores",
"following": "Siguiendo",
"viewOnSite": "Ver en el sitio"
},
"remote": {
"follow": "Seguir",
"unfollow": "Dejar de seguir",
"viewOn": "Ver en",
"postsTitle": "Publicaciones",
"noPosts": "Aún no hay publicaciones de esta cuenta.",
"followToSee": "Sigue esta cuenta para ver sus publicaciones en tu línea de tiempo.",
"notFound": "No se pudo encontrar esta cuenta. Puede haber sido eliminada o el servidor puede no estar disponible."
}
},
"migrate": {
"title": "Migración de Mastodon",
"intro": "Esta guía te lleva paso a paso por el traslado de tu identidad de Mastodon a tu sitio IndieWeb. Completa cada paso en orden — tus seguidores existentes serán notificados y podrán volver a seguirte automáticamente.",
"step1Title": "Paso 1 — Vincular tu cuenta anterior",
"step1Desc": "Indica al fediverse que tu cuenta anterior de Mastodon y este sitio pertenecen a la misma persona. Esto establece la propiedad <code>alsoKnownAs</code> en tu actor de ActivityPub, que Mastodon verifica antes de permitir un traslado.",
"aliasLabel": "URL de la cuenta anterior de Mastodon",
"aliasHint": "La URL completa de tu perfil de Mastodon, p. ej. https://mstdn.social/users/rmdes",
"aliasSave": "Guardar alias",
"aliasCurrent": "Alias actual",
"aliasNone": "Aún no se ha configurado ningún alias.",
"step2Title": "Paso 2 — Importar tu red social",
"step2Desc": "Sube los archivos CSV de tu exportación de datos de Mastodon para traer tus conexiones. Ve a tu instancia de Mastodon → Preferencias → Importar y exportar → Exportación de datos para descargarlos.",
"importLegend": "Qué importar",
"fileLabel": "Archivo CSV",
"fileHint": "Selecciona un archivo CSV de tu exportación de datos de Mastodon (p. ej. following_accounts.csv o followers.csv)",
"importButton": "Importar",
"importFollowing": "Lista de seguidos",
"importFollowingHint": "Cuentas que sigues — aparecerán en tu lista de Siguiendo de inmediato",
"importFollowers": "Lista de seguidores",
"importFollowersHint": "Tus seguidores actuales — se registrarán como pendientes hasta que vuelvan a seguirte después del traslado en el paso 3",
"step3Title": "Paso 3 — Trasladar tu cuenta",
"step3Desc": "Una vez que hayas guardado tu alias e importado tus datos, ve a tu instancia de Mastodon → Preferencias → Cuenta → <strong>Trasladar a una cuenta diferente</strong>. Ingresa tu nuevo identificador del fediverse y confirma. Mastodon notificará a todos tus seguidores, y aquellos cuyos servidores lo soporten te seguirán automáticamente aquí. Este paso es irreversible — tu cuenta anterior se convertirá en una redirección.",
"errorNoFile": "Por favor, selecciona un archivo CSV antes de importar.",
"success": "Se importaron %d seguidos, %d seguidores (%d fallidos).",
"failedList": "No se pudieron resolver: %s",
"failedListSummary": "Identificadores fallidos",
"aliasSuccess": "Alias guardado exitosamente — tu documento de actor ahora incluye esta cuenta como alsoKnownAs."
},
"refollow": {
"title": "Re-seguimiento por lotes",
"progress": "Progreso de re-seguimiento",
"remaining": "Restantes",
"awaitingAccept": "Esperando aceptación",
"accepted": "Aceptado",
"failed": "Fallido",
"pause": "Pausar",
"resume": "Reanudar",
"status": {
"idle": "Inactivo",
"running": "En ejecución",
"paused": "Pausado",
"completed": "Completado"
}
},
"moderation": {
"title": "Moderación",
"blockedTitle": "Cuentas bloqueadas",
"mutedActorsTitle": "Cuentas silenciadas",
"mutedKeywordsTitle": "Palabras clave silenciadas",
"noBlocked": "No hay cuentas bloqueadas.",
"noMutedActors": "No hay cuentas silenciadas.",
"noMutedKeywords": "No hay palabras clave silenciadas.",
"unblock": "Desbloquear",
"unmute": "Desilenciar",
"addKeywordTitle": "Agregar palabra clave silenciada",
"keywordPlaceholder": "Ingresar palabra clave o frase…",
"addKeyword": "Agregar",
"muteActor": "Silenciar",
"blockActor": "Bloquear",
"filterModeTitle": "Modo de filtrado",
"filterModeHint": "Elige cómo se administra el contenido silenciado en tu línea de tiempo. Las cuentas bloqueadas siempre se ocultan.",
"filterModeHide": "Ocultar — eliminar de la línea de tiempo",
"filterModeWarn": "Advertir — mostrar detrás de advertencia de contenido",
"cwMutedAccount": "Cuenta silenciada",
"cwMutedKeyword": "Palabra clave silenciada:",
"cwFiltered": "Contenido filtrado"
},
"compose": {
"title": "Redactar respuesta",
"placeholder": "Escribe tu respuesta…",
"syndicateLabel": "Sindicar a",
"submitMicropub": "Publicar respuesta",
"cancel": "Cancelar",
"errorEmpty": "El contenido de la respuesta no puede estar vacío",
"visibilityLabel": "Visibilidad",
"visibilityPublic": "Público",
"visibilityUnlisted": "No listado",
"visibilityFollowers": "Solo seguidores",
"cwLabel": "Advertencia de contenido",
"cwPlaceholder": "Escribe tu advertencia aquí…"
},
"notifications": {
"title": "Notificaciones",
"empty": "Aún no hay notificaciones. Las interacciones de otros usuarios del fediverse aparecerán aquí.",
"liked": "le dio me gusta a tu publicación",
"boostedPost": "impulsó tu publicación",
"followedYou": "te siguió",
"repliedTo": "respondió a tu publicación",
"mentionedYou": "te mencionó",
"markAllRead": "Marcar todo como leído",
"clearAll": "Borrar todo",
"clearConfirm": "¿Eliminar todas las notificaciones? Esto no se puede deshacer.",
"dismiss": "Descartar",
"viewThread": "Ver hilo",
"tabs": {
"all": "Todas",
"replies": "Respuestas",
"likes": "Me gusta",
"boosts": "Impulsos",
"follows": "Seguimientos",
"dms": "MDs",
"reports": "Reportes"
},
"emptyTab": "Aún no hay notificaciones de %s."
},
"messages": {
"title": "Mensajes",
"empty": "Aún no hay mensajes. Los mensajes directos de otros usuarios del fediverse aparecerán aquí.",
"allConversations": "Todas las conversaciones",
"compose": "Nuevo mensaje",
"send": "Enviar mensaje",
"delete": "Eliminar",
"markAllRead": "Marcar todo como leído",
"clearAll": "Borrar todo",
"clearConfirm": "¿Eliminar todos los mensajes? Esto no se puede deshacer.",
"recipientLabel": "Para",
"recipientPlaceholder": "@usuario@instancia.social",
"placeholder": "Escribe tu mensaje...",
"sentTo": "Para",
"replyingTo": "Respondiendo a",
"sentYouDM": "te envió un mensaje directo",
"viewMessage": "Ver mensaje",
"errorEmpty": "El contenido del mensaje no puede estar vacío.",
"errorNoRecipient": "Por favor, ingresa un destinatario.",
"errorRecipientNotFound": "No se pudo encontrar a ese usuario. Intenta con un identificador completo @usuario@dominio."
},
"reader": {
"title": "Lector",
"tabs": {
"all": "Todo",
"notes": "Notas",
"articles": "Artículos",
"replies": "Respuestas",
"boosts": "Impulsos",
"media": "Medios"
},
"empty": "Tu línea de tiempo está vacía. Sigue algunas cuentas para ver sus publicaciones aquí.",
"boosted": "impulsó",
"replyingTo": "Respondiendo a",
"showContent": "Mostrar contenido",
"hideContent": "Ocultar contenido",
"sensitiveContent": "Contenido sensible",
"videoNotSupported": "Tu navegador no soporta el elemento de video.",
"audioNotSupported": "Tu navegador no soporta el elemento de audio.",
"actions": {
"reply": "Responder",
"boost": "Impulsar",
"unboost": "Deshacer impulso",
"like": "Me gusta",
"unlike": "Deshacer me gusta",
"viewOriginal": "Ver original",
"liked": "Me gusta",
"boosted": "Impulsado",
"likeError": "No se pudo dar me gusta a esta publicación",
"boostError": "No se pudo impulsar esta publicación"
},
"post": {
"title": "Detalle de la publicación",
"notFound": "Publicación no encontrada o ya no disponible.",
"openExternal": "Abrir en la instancia original",
"parentPosts": "Hilo",
"replies": "Respuestas",
"back": "Volver a la línea de tiempo",
"loadingThread": "Cargando hilo...",
"threadError": "No se pudo cargar el hilo completo"
},
"resolve": {
"placeholder": "Pega una URL del fediverse o un identificador @usuario@dominio…",
"label": "Buscar una publicación o cuenta del fediverse",
"button": "Buscar",
"notFoundTitle": "No encontrado",
"notFound": "No se pudo encontrar esta publicación o cuenta. La URL puede ser inválida, el servidor puede no estar disponible o el contenido puede haber sido eliminado.",
"followersLabel": "seguidores"
},
"linkPreview": {
"label": "Vista previa del enlace"
},
"explore": {
"title": "Explorar",
"description": "Explora líneas de tiempo públicas de instancias remotas compatibles con Mastodon.",
"instancePlaceholder": "Ingresa un nombre de host de instancia, p. ej. mastodon.social",
"browse": "Explorar",
"local": "Local",
"federated": "Federada",
"loadError": "No se pudo cargar la línea de tiempo de esta instancia. Puede no estar disponible o no ser compatible con la API de Mastodon.",
"timeout": "La solicitud se agotó. La instancia puede estar lenta o no disponible.",
"noResults": "No se encontraron publicaciones en la línea de tiempo pública de esta instancia.",
"invalidInstance": "Nombre de host de instancia inválido. Por favor, ingresa un nombre de dominio válido.",
"mauLabel": "MAU",
"timelineSupported": "Línea de tiempo pública disponible",
"timelineUnsupported": "Línea de tiempo pública no disponible",
"hashtagLabel": "Hashtag (opcional)",
"hashtagPlaceholder": "p. ej. indieweb",
"hashtagHint": "Filtrar resultados por un hashtag específico",
"tabs": {
"label": "Pestañas de exploración",
"search": "Buscar",
"pinAsTab": "Fijar como pestaña",
"pinned": "Fijadas",
"remove": "Eliminar pestaña",
"moveUp": "Subir",
"moveDown": "Bajar",
"addHashtag": "Agregar pestaña de hashtag",
"hashtagTabPlaceholder": "Ingresar hashtag",
"addTab": "Agregar",
"retry": "Reintentar",
"noInstances": "Fija primero algunas instancias para usar pestañas de hashtag.",
"sources": "Buscando #%s en %d instancia",
"sources_plural": "Buscando #%s en %d instancias",
"sourcesPartial": "%d de %d instancias respondieron"
}
},
"tagTimeline": {
"postsTagged": "%d publicación",
"postsTagged_plural": "%d publicaciones",
"noPosts": "No se encontraron publicaciones con #%s en tu línea de tiempo.",
"followTag": "Seguir hashtag",
"unfollowTag": "Dejar de seguir hashtag",
"following": "Siguiendo"
},
"pagination": {
"newer": "← Más recientes",
"older": "Más antiguas →",
"loadMore": "Cargar más",
"loading": "Cargando…",
"noMore": "Estás al día."
}
},
"myProfile": {
"title": "Mi perfil",
"posts": "publicaciones",
"editProfile": "Editar perfil",
"empty": "Nada aquí todavía.",
"tabs": {
"posts": "Publicaciones",
"replies": "Respuestas",
"likes": "Me gusta",
"boosts": "Impulsos"
}
},
"poll": {
"voters": "votantes",
"votes": "votos",
"closed": "Encuesta cerrada",
"endsAt": "Termina"
},
"federation": {
"deleteSuccess": "Actividad de eliminación enviada exitosamente a los seguidores",
"deleteButton": "Eliminar del fediverse"
},
"federationMgmt": {
"title": "Federación",
"collections": "Estado de las colecciones",
"quickActions": "Acciones rápidas",
"broadcastActor": "Difundir actualización del actor",
"debugDashboard": "Panel de depuración",
"objectLookup": "Búsqueda de objeto",
"lookupPlaceholder": "URL o identificador @usuario@dominio…",
"lookup": "Buscar",
"lookupLoading": "Resolviendo…",
"postActions": "Federación de publicaciones",
"viewJson": "JSON",
"rebroadcast": "Re-difundir actividad Create",
"rebroadcastShort": "Reenviar",
"broadcastDelete": "Difundir actividad Delete",
"deleteShort": "Eliminar",
"noPosts": "No se encontraron publicaciones.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Actividad reciente",
"viewAllActivities": "Ver todas las actividades →"
},
"reports": {
"sentReport": "presentó un reporte",
"title": "Reportes"
}
}
}

358
locales/es.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Seguidores",
"following": "Siguiendo",
"activities": "Registro de actividad",
"featured": "Publicaciones fijadas",
"featuredTags": "Etiquetas destacadas",
"recentActivity": "Actividad reciente",
"noActivity": "Aún no hay actividad. Una vez que tu actor esté federado, las interacciones aparecerán aquí.",
"noFollowers": "Aún no hay seguidores.",
"noFollowing": "Aún no sigues a nadie.",
"pendingFollows": "Pendientes",
"noPendingFollows": "No hay solicitudes de seguimiento pendientes.",
"approve": "Aprobar",
"reject": "Rechazar",
"followApproved": "Solicitud de seguimiento aprobada.",
"followRejected": "Solicitud de seguimiento rechazada.",
"followRequest": "ha solicitado seguirte",
"followerCount": "%d seguidor",
"followerCount_plural": "%d seguidores",
"followingCount": "%d siguiendo",
"followedAt": "Siguiendo desde",
"source": "Origen",
"sourceImport": "Importación de Mastodon",
"sourceManual": "Manual",
"sourceFederation": "Federación",
"sourceRefollowPending": "Re-seguimiento pendiente",
"sourceRefollowFailed": "Re-seguimiento fallido",
"direction": "Dirección",
"directionInbound": "Recibido",
"directionOutbound": "Enviado",
"profile": {
"title": "Perfil",
"intro": "Edita cómo tu actor aparece ante otros usuarios del fediverse. Los cambios surten efecto inmediatamente.",
"nameLabel": "Nombre para mostrar",
"nameHint": "Tu nombre tal como se muestra en tu perfil del fediverse",
"summaryLabel": "Biografía",
"summaryHint": "Una breve descripción sobre ti. Se permite HTML.",
"urlLabel": "URL del sitio web",
"urlHint": "La dirección de tu sitio web, mostrada como enlace en tu perfil",
"iconLabel": "URL del avatar",
"iconHint": "URL de tu imagen de perfil (cuadrada, se recomienda al menos 400x400px)",
"imageLabel": "URL de la imagen de cabecera",
"imageHint": "URL de una imagen de banner mostrada en la parte superior de tu perfil",
"manualApprovalLabel": "Aprobar seguidores manualmente",
"manualApprovalHint": "Cuando está activado, las solicitudes de seguimiento requieren tu aprobación antes de que surtan efecto",
"actorTypeLabel": "Tipo de actor",
"actorTypeHint": "Cómo aparece tu cuenta en el fediverse. Person para individuos, Service para bots o cuentas automatizadas, Organization para grupos o empresas.",
"linksLabel": "Enlaces del perfil",
"linksHint": "Enlaces mostrados en tu perfil del fediverse. Añade tu sitio web, cuentas sociales u otras URLs. Las páginas que enlacen con rel=\"me\" se mostrarán como verificadas en Mastodon.",
"linkNameLabel": "Etiqueta",
"linkValueLabel": "URL",
"addLink": "Añadir enlace",
"removeLink": "Eliminar",
"authorizedFetchLabel": "Requerir obtención autorizada (modo seguro)",
"authorizedFetchHint": "Cuando está activado, solo los servidores con firmas HTTP válidas pueden obtener tu actor y colecciones. Esto mejora la privacidad pero puede reducir la compatibilidad con algunos clientes.",
"save": "Guardar perfil",
"saved": "Perfil guardado. Los cambios son ahora visibles en el fediverse.",
"public": {
"followPrompt": "Sígueme en el fediverse",
"copyHandle": "Copiar identificador",
"copied": "¡Copiado!",
"pinnedPosts": "Publicaciones fijadas",
"recentPosts": "Publicaciones recientes",
"joinedDate": "Se unió",
"posts": "Publicaciones",
"followers": "Seguidores",
"following": "Siguiendo",
"viewOnSite": "Ver en el sitio"
},
"remote": {
"follow": "Seguir",
"unfollow": "Dejar de seguir",
"viewOn": "Ver en",
"postsTitle": "Publicaciones",
"noPosts": "Aún no hay publicaciones de esta cuenta.",
"followToSee": "Sigue esta cuenta para ver sus publicaciones en tu línea temporal.",
"notFound": "No se pudo encontrar esta cuenta. Puede haber sido eliminada o el servidor puede no estar disponible."
}
},
"migrate": {
"title": "Migración de Mastodon",
"intro": "Esta guía te acompaña en el traslado de tu identidad de Mastodon a tu sitio IndieWeb. Completa cada paso en orden — tus seguidores existentes serán notificados y podrán volver a seguirte automáticamente.",
"step1Title": "Paso 1 — Vincular tu cuenta antigua",
"step1Desc": "Indica al fediverse que tu antigua cuenta de Mastodon y este sitio pertenecen a la misma persona. Esto establece la propiedad <code>alsoKnownAs</code> en tu actor de ActivityPub, que Mastodon comprueba antes de permitir un traslado.",
"aliasLabel": "URL de la cuenta antigua de Mastodon",
"aliasHint": "La URL completa de tu perfil de Mastodon, p. ej. https://mstdn.social/users/rmdes",
"aliasSave": "Guardar alias",
"aliasCurrent": "Alias actual",
"aliasNone": "Aún no se ha configurado ningún alias.",
"step2Title": "Paso 2 — Importar tu red social",
"step2Desc": "Sube los archivos CSV de tu exportación de datos de Mastodon para traer tus conexiones. Ve a tu instancia de Mastodon → Preferencias → Importar y exportar → Exportación de datos para descargarlos.",
"importLegend": "Qué importar",
"fileLabel": "Archivo CSV",
"fileHint": "Selecciona un archivo CSV de tu exportación de datos de Mastodon (p. ej. following_accounts.csv o followers.csv)",
"importButton": "Importar",
"importFollowing": "Lista de seguidos",
"importFollowingHint": "Cuentas que sigues — aparecerán en tu lista de Siguiendo inmediatamente",
"importFollowers": "Lista de seguidores",
"importFollowersHint": "Tus seguidores actuales — se registrarán como pendientes hasta que vuelvan a seguirte después del traslado en el paso 3",
"step3Title": "Paso 3 — Trasladar tu cuenta",
"step3Desc": "Una vez que hayas guardado tu alias e importado tus datos, ve a tu instancia de Mastodon → Preferencias → Cuenta → <strong>Trasladar a una cuenta diferente</strong>. Introduce tu nuevo identificador del fediverse y confirma. Mastodon notificará a todos tus seguidores, y aquellos cuyos servidores lo soporten te seguirán automáticamente aquí. Este paso es irreversible — tu cuenta antigua se convertirá en una redirección.",
"errorNoFile": "Por favor, selecciona un archivo CSV antes de importar.",
"success": "Importados %d seguidos, %d seguidores (%d fallidos).",
"failedList": "No se pudieron resolver: %s",
"failedListSummary": "Identificadores fallidos",
"aliasSuccess": "Alias guardado — tu documento de actor ahora incluye esta cuenta como alsoKnownAs."
},
"refollow": {
"title": "Re-seguimiento por lotes",
"progress": "Progreso de re-seguimiento",
"remaining": "Restantes",
"awaitingAccept": "Esperando aceptación",
"accepted": "Aceptado",
"failed": "Fallido",
"pause": "Pausar",
"resume": "Reanudar",
"status": {
"idle": "Inactivo",
"running": "En ejecución",
"paused": "Pausado",
"completed": "Completado"
}
},
"moderation": {
"title": "Moderación",
"blockedTitle": "Cuentas bloqueadas",
"mutedActorsTitle": "Cuentas silenciadas",
"mutedKeywordsTitle": "Palabras clave silenciadas",
"noBlocked": "No hay cuentas bloqueadas.",
"noMutedActors": "No hay cuentas silenciadas.",
"noMutedKeywords": "No hay palabras clave silenciadas.",
"unblock": "Desbloquear",
"unmute": "Desilenciar",
"addKeywordTitle": "Añadir palabra clave silenciada",
"keywordPlaceholder": "Introducir palabra clave o frase…",
"addKeyword": "Añadir",
"muteActor": "Silenciar",
"blockActor": "Bloquear",
"filterModeTitle": "Modo de filtrado",
"filterModeHint": "Elige cómo se gestiona el contenido silenciado en tu línea temporal. Las cuentas bloqueadas siempre se ocultan.",
"filterModeHide": "Ocultar — eliminar de la línea temporal",
"filterModeWarn": "Avisar — mostrar tras advertencia de contenido",
"cwMutedAccount": "Cuenta silenciada",
"cwMutedKeyword": "Palabra clave silenciada:",
"cwFiltered": "Contenido filtrado"
},
"compose": {
"title": "Redactar respuesta",
"placeholder": "Escribe tu respuesta…",
"syndicateLabel": "Sindicar a",
"submitMicropub": "Publicar respuesta",
"cancel": "Cancelar",
"errorEmpty": "El contenido de la respuesta no puede estar vacío",
"visibilityLabel": "Visibilidad",
"visibilityPublic": "Público",
"visibilityUnlisted": "No listado",
"visibilityFollowers": "Solo seguidores",
"cwLabel": "Advertencia de contenido",
"cwPlaceholder": "Escribe tu advertencia aquí…"
},
"notifications": {
"title": "Notificaciones",
"empty": "Aún no hay notificaciones. Las interacciones de otros usuarios del fediverse aparecerán aquí.",
"liked": "le gustó tu publicación",
"boostedPost": "impulsó tu publicación",
"followedYou": "te siguió",
"repliedTo": "respondió a tu publicación",
"mentionedYou": "te mencionó",
"markAllRead": "Marcar todo como leído",
"clearAll": "Borrar todo",
"clearConfirm": "¿Eliminar todas las notificaciones? Esto no se puede deshacer.",
"dismiss": "Descartar",
"viewThread": "Ver hilo",
"tabs": {
"all": "Todas",
"replies": "Respuestas",
"likes": "Me gusta",
"boosts": "Impulsos",
"follows": "Seguimientos",
"dms": "MDs",
"reports": "Informes"
},
"emptyTab": "Aún no hay notificaciones de %s."
},
"messages": {
"title": "Mensajes",
"empty": "Aún no hay mensajes. Los mensajes directos de otros usuarios del fediverse aparecerán aquí.",
"allConversations": "Todas las conversaciones",
"compose": "Nuevo mensaje",
"send": "Enviar mensaje",
"delete": "Eliminar",
"markAllRead": "Marcar todo como leído",
"clearAll": "Borrar todo",
"clearConfirm": "¿Eliminar todos los mensajes? Esto no se puede deshacer.",
"recipientLabel": "Para",
"recipientPlaceholder": "@usuario@instancia.social",
"placeholder": "Escribe tu mensaje...",
"sentTo": "Para",
"replyingTo": "Respondiendo a",
"sentYouDM": "te envió un mensaje directo",
"viewMessage": "Ver mensaje",
"errorEmpty": "El contenido del mensaje no puede estar vacío.",
"errorNoRecipient": "Por favor, introduce un destinatario.",
"errorRecipientNotFound": "No se pudo encontrar a ese usuario. Prueba con un identificador completo @usuario@dominio."
},
"reader": {
"title": "Lector",
"tabs": {
"all": "Todo",
"notes": "Notas",
"articles": "Artículos",
"replies": "Respuestas",
"boosts": "Impulsos",
"media": "Medios"
},
"empty": "Tu línea temporal está vacía. Sigue algunas cuentas para ver sus publicaciones aquí.",
"boosted": "impulsó",
"replyingTo": "Respondiendo a",
"showContent": "Mostrar contenido",
"hideContent": "Ocultar contenido",
"sensitiveContent": "Contenido sensible",
"videoNotSupported": "Tu navegador no admite el elemento de vídeo.",
"audioNotSupported": "Tu navegador no admite el elemento de audio.",
"actions": {
"reply": "Responder",
"boost": "Impulsar",
"unboost": "Deshacer impulso",
"like": "Me gusta",
"unlike": "Deshacer me gusta",
"viewOriginal": "Ver original",
"liked": "Me gusta",
"boosted": "Impulsado",
"likeError": "No se pudo dar me gusta a esta publicación",
"boostError": "No se pudo impulsar esta publicación"
},
"post": {
"title": "Detalle de la publicación",
"notFound": "Publicación no encontrada o ya no disponible.",
"openExternal": "Abrir en la instancia original",
"parentPosts": "Hilo",
"replies": "Respuestas",
"back": "Volver a la línea temporal",
"loadingThread": "Cargando hilo...",
"threadError": "No se pudo cargar el hilo completo"
},
"resolve": {
"placeholder": "Pega una URL del fediverse o un identificador @usuario@dominio…",
"label": "Buscar una publicación o cuenta del fediverse",
"button": "Buscar",
"notFoundTitle": "No encontrado",
"notFound": "No se pudo encontrar esta publicación o cuenta. La URL puede ser inválida, el servidor puede no estar disponible o el contenido puede haber sido eliminado.",
"followersLabel": "seguidores"
},
"linkPreview": {
"label": "Vista previa del enlace"
},
"explore": {
"title": "Explorar",
"description": "Explora líneas temporales públicas de instancias remotas compatibles con Mastodon.",
"instancePlaceholder": "Introduce un nombre de host de instancia, p. ej. mastodon.social",
"browse": "Explorar",
"local": "Local",
"federated": "Federada",
"loadError": "No se pudo cargar la línea temporal de esta instancia. Puede no estar disponible o no ser compatible con la API de Mastodon.",
"timeout": "La solicitud ha expirado. La instancia puede estar lenta o no disponible.",
"noResults": "No se encontraron publicaciones en la línea temporal pública de esta instancia.",
"invalidInstance": "Nombre de host de instancia inválido. Por favor, introduce un nombre de dominio válido.",
"mauLabel": "MAU",
"timelineSupported": "Línea temporal pública disponible",
"timelineUnsupported": "Línea temporal pública no disponible",
"hashtagLabel": "Hashtag (opcional)",
"hashtagPlaceholder": "p. ej. indieweb",
"hashtagHint": "Filtrar resultados por un hashtag específico",
"tabs": {
"label": "Pestañas de exploración",
"search": "Buscar",
"pinAsTab": "Fijar como pestaña",
"pinned": "Fijadas",
"remove": "Eliminar pestaña",
"moveUp": "Subir",
"moveDown": "Bajar",
"addHashtag": "Añadir pestaña de hashtag",
"hashtagTabPlaceholder": "Introducir hashtag",
"addTab": "Añadir",
"retry": "Reintentar",
"noInstances": "Fija primero algunas instancias para utilizar pestañas de hashtag.",
"sources": "Buscando #%s en %d instancia",
"sources_plural": "Buscando #%s en %d instancias",
"sourcesPartial": "%d de %d instancias respondieron"
}
},
"tagTimeline": {
"postsTagged": "%d publicación",
"postsTagged_plural": "%d publicaciones",
"noPosts": "No se encontraron publicaciones con #%s en tu línea temporal.",
"followTag": "Seguir hashtag",
"unfollowTag": "Dejar de seguir hashtag",
"following": "Siguiendo"
},
"pagination": {
"newer": "← Más recientes",
"older": "Más antiguas →",
"loadMore": "Cargar más",
"loading": "Cargando…",
"noMore": "Estás al día."
}
},
"myProfile": {
"title": "Mi perfil",
"posts": "publicaciones",
"editProfile": "Editar perfil",
"empty": "Nada aquí todavía.",
"tabs": {
"posts": "Publicaciones",
"replies": "Respuestas",
"likes": "Me gusta",
"boosts": "Impulsos"
}
},
"poll": {
"voters": "votantes",
"votes": "votos",
"closed": "Encuesta cerrada",
"endsAt": "Termina"
},
"federation": {
"deleteSuccess": "Actividad de eliminación enviada a los seguidores",
"deleteButton": "Eliminar del fediverse"
},
"federationMgmt": {
"title": "Federación",
"collections": "Estado de las colecciones",
"quickActions": "Acciones rápidas",
"broadcastActor": "Difundir actualización del actor",
"debugDashboard": "Panel de depuración",
"objectLookup": "Búsqueda de objeto",
"lookupPlaceholder": "URL o identificador @usuario@dominio…",
"lookup": "Buscar",
"lookupLoading": "Resolviendo…",
"postActions": "Federación de publicaciones",
"viewJson": "JSON",
"rebroadcast": "Re-difundir actividad Create",
"rebroadcastShort": "Reenviar",
"broadcastDelete": "Difundir actividad Delete",
"deleteShort": "Eliminar",
"noPosts": "No se encontraron publicaciones.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Actividad reciente",
"viewAllActivities": "Ver todas las actividades →"
},
"reports": {
"sentReport": "presentó un informe",
"title": "Informes"
}
}
}

358
locales/fr.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Abonnés",
"following": "Abonnements",
"activities": "Journal d'activité",
"featured": "Publications épinglées",
"featuredTags": "Hashtags mis en avant",
"recentActivity": "Activité récente",
"noActivity": "Pas encore d'activité. Une fois votre acteur fédéré, les interactions apparaîtront ici.",
"noFollowers": "Pas encore d'abonnés.",
"noFollowing": "Vous ne suivez encore personne.",
"pendingFollows": "En attente",
"noPendingFollows": "Aucune demande d'abonnement en attente.",
"approve": "Approuver",
"reject": "Rejeter",
"followApproved": "Demande d'abonnement approuvée.",
"followRejected": "Demande d'abonnement rejetée.",
"followRequest": "a demandé à vous suivre",
"followerCount": "%d abonné",
"followerCount_plural": "%d abonnés",
"followingCount": "%d abonnement(s)",
"followedAt": "Abonné depuis",
"source": "Source",
"sourceImport": "Import Mastodon",
"sourceManual": "Manuel",
"sourceFederation": "Fédération",
"sourceRefollowPending": "Ré-abonnement en attente",
"sourceRefollowFailed": "Ré-abonnement échoué",
"direction": "Direction",
"directionInbound": "Reçu",
"directionOutbound": "Envoyé",
"profile": {
"title": "Profil",
"intro": "Modifiez l'apparence de votre acteur pour les autres utilisateurs du fediverse. Les modifications prennent effet immédiatement.",
"nameLabel": "Nom affiché",
"nameHint": "Votre nom tel qu'il apparaît sur votre profil fediverse",
"summaryLabel": "Biographie",
"summaryHint": "Une courte description de vous-même. Le HTML est autorisé.",
"urlLabel": "URL du site web",
"urlHint": "L'adresse de votre site web, affichée comme lien sur votre profil",
"iconLabel": "URL de l'avatar",
"iconHint": "URL de votre photo de profil (carrée, 400x400px minimum recommandé)",
"imageLabel": "URL de l'image d'en-tête",
"imageHint": "URL d'une image bannière affichée en haut de votre profil",
"manualApprovalLabel": "Approuver manuellement les abonnés",
"manualApprovalHint": "Lorsque activé, les demandes d'abonnement nécessitent votre approbation avant de prendre effet",
"actorTypeLabel": "Type d'acteur",
"actorTypeHint": "Comment votre compte apparaît dans le fediverse. Person pour les individus, Service pour les bots ou comptes automatisés, Organization pour les groupes ou entreprises.",
"linksLabel": "Liens du profil",
"linksHint": "Liens affichés sur votre profil fediverse. Ajoutez votre site web, comptes sociaux ou autres URLs. Les pages qui renvoient avec rel=\"me\" seront affichées comme vérifiées sur Mastodon.",
"linkNameLabel": "Libellé",
"linkValueLabel": "URL",
"addLink": "Ajouter un lien",
"removeLink": "Supprimer",
"authorizedFetchLabel": "Exiger la récupération autorisée (mode sécurisé)",
"authorizedFetchHint": "Lorsque activé, seuls les serveurs avec des signatures HTTP valides peuvent récupérer votre acteur et vos collections. Cela améliore la confidentialité mais peut réduire la compatibilité avec certains clients.",
"save": "Enregistrer le profil",
"saved": "Profil enregistré. Les modifications sont maintenant visibles dans le fediverse.",
"public": {
"followPrompt": "Suivez-moi sur le fediverse",
"copyHandle": "Copier l'identifiant",
"copied": "Copié !",
"pinnedPosts": "Publications épinglées",
"recentPosts": "Publications récentes",
"joinedDate": "Inscrit",
"posts": "Publications",
"followers": "Abonnés",
"following": "Abonnements",
"viewOnSite": "Voir sur le site"
},
"remote": {
"follow": "Suivre",
"unfollow": "Se désabonner",
"viewOn": "Voir sur",
"postsTitle": "Publications",
"noPosts": "Pas encore de publications de ce compte.",
"followToSee": "Suivez ce compte pour voir ses publications dans votre fil.",
"notFound": "Impossible de trouver ce compte. Il a peut-être été supprimé ou le serveur est peut-être indisponible."
}
},
"migrate": {
"title": "Migration Mastodon",
"intro": "Ce guide vous accompagne dans le transfert de votre identité Mastodon vers votre site IndieWeb. Complétez chaque étape dans l'ordre — vos abonnés existants seront notifiés et pourront vous suivre automatiquement.",
"step1Title": "Étape 1 — Lier votre ancien compte",
"step1Desc": "Indiquez au fediverse que votre ancien compte Mastodon et ce site appartiennent à la même personne. Cela définit la propriété <code>alsoKnownAs</code> sur votre acteur ActivityPub, que Mastodon vérifie avant d'autoriser un déplacement.",
"aliasLabel": "URL de l'ancien compte Mastodon",
"aliasHint": "L'URL complète de votre profil Mastodon, ex. https://mstdn.social/users/rmdes",
"aliasSave": "Enregistrer l'alias",
"aliasCurrent": "Alias actuel",
"aliasNone": "Aucun alias configuré.",
"step2Title": "Étape 2 — Importer votre réseau social",
"step2Desc": "Téléversez les fichiers CSV de votre export de données Mastodon pour récupérer vos connexions. Allez sur votre instance Mastodon → Préférences → Import et export → Export des données pour les télécharger.",
"importLegend": "Quoi importer",
"fileLabel": "Fichier CSV",
"fileHint": "Sélectionnez un fichier CSV de votre export de données Mastodon (ex. following_accounts.csv ou followers.csv)",
"importButton": "Importer",
"importFollowing": "Liste des abonnements",
"importFollowingHint": "Comptes que vous suivez — ils apparaîtront immédiatement dans votre liste d'abonnements",
"importFollowers": "Liste des abonnés",
"importFollowersHint": "Vos abonnés actuels — ils seront enregistrés comme en attente jusqu'à ce qu'ils vous suivent à nouveau après le déplacement à l'étape 3",
"step3Title": "Étape 3 — Déplacer votre compte",
"step3Desc": "Une fois votre alias enregistré et vos données importées, allez sur votre instance Mastodon → Préférences → Compte → <strong>Déplacer vers un autre compte</strong>. Entrez votre nouvel identifiant fediverse et confirmez. Mastodon notifiera tous vos abonnés, et ceux dont les serveurs le supportent vous suivront automatiquement ici. Cette étape est irréversible — votre ancien compte deviendra une redirection.",
"errorNoFile": "Veuillez sélectionner un fichier CSV avant d'importer.",
"success": "%d abonnements, %d abonnés importés (%d échoués).",
"failedList": "Impossible de résoudre : %s",
"failedListSummary": "Identifiants échoués",
"aliasSuccess": "Alias enregistré — votre document acteur inclut maintenant ce compte comme alsoKnownAs."
},
"refollow": {
"title": "Ré-abonnement par lot",
"progress": "Progression du ré-abonnement",
"remaining": "Restant",
"awaitingAccept": "En attente d'acceptation",
"accepted": "Accepté",
"failed": "Échoué",
"pause": "Pause",
"resume": "Reprendre",
"status": {
"idle": "Inactif",
"running": "En cours",
"paused": "En pause",
"completed": "Terminé"
}
},
"moderation": {
"title": "Modération",
"blockedTitle": "Comptes bloqués",
"mutedActorsTitle": "Comptes masqués",
"mutedKeywordsTitle": "Mots-clés masqués",
"noBlocked": "Aucun compte bloqué.",
"noMutedActors": "Aucun compte masqué.",
"noMutedKeywords": "Aucun mot-clé masqué.",
"unblock": "Débloquer",
"unmute": "Démasquer",
"addKeywordTitle": "Ajouter un mot-clé masqué",
"keywordPlaceholder": "Entrez un mot-clé ou une phrase…",
"addKeyword": "Ajouter",
"muteActor": "Masquer",
"blockActor": "Bloquer",
"filterModeTitle": "Mode de filtrage",
"filterModeHint": "Choisissez comment le contenu masqué est géré dans votre fil. Les comptes bloqués sont toujours cachés.",
"filterModeHide": "Masquer — retirer du fil",
"filterModeWarn": "Avertir — afficher derrière un avertissement de contenu",
"cwMutedAccount": "Compte masqué",
"cwMutedKeyword": "Mot-clé masqué :",
"cwFiltered": "Contenu filtré"
},
"compose": {
"title": "Rédiger une réponse",
"placeholder": "Écrivez votre réponse…",
"syndicateLabel": "Syndiquer vers",
"submitMicropub": "Publier la réponse",
"cancel": "Annuler",
"errorEmpty": "Le contenu de la réponse ne peut pas être vide",
"visibilityLabel": "Visibilité",
"visibilityPublic": "Public",
"visibilityUnlisted": "Non listé",
"visibilityFollowers": "Abonnés uniquement",
"cwLabel": "Avertissement de contenu",
"cwPlaceholder": "Écrivez votre avertissement ici…"
},
"notifications": {
"title": "Notifications",
"empty": "Pas encore de notifications. Les interactions d'autres utilisateurs du fediverse apparaîtront ici.",
"liked": "a aimé votre publication",
"boostedPost": "a partagé votre publication",
"followedYou": "vous a suivi",
"repliedTo": "a répondu à votre publication",
"mentionedYou": "vous a mentionné",
"markAllRead": "Tout marquer comme lu",
"clearAll": "Tout effacer",
"clearConfirm": "Supprimer toutes les notifications ? Cette action est irréversible.",
"dismiss": "Ignorer",
"viewThread": "Voir le fil",
"tabs": {
"all": "Toutes",
"replies": "Réponses",
"likes": "J'aime",
"boosts": "Partages",
"follows": "Abonnements",
"dms": "MPs",
"reports": "Signalements"
},
"emptyTab": "Pas encore de notifications %s."
},
"messages": {
"title": "Messages",
"empty": "Pas encore de messages. Les messages directs d'autres utilisateurs du fediverse apparaîtront ici.",
"allConversations": "Toutes les conversations",
"compose": "Nouveau message",
"send": "Envoyer le message",
"delete": "Supprimer",
"markAllRead": "Tout marquer comme lu",
"clearAll": "Tout effacer",
"clearConfirm": "Supprimer tous les messages ? Cette action est irréversible.",
"recipientLabel": "À",
"recipientPlaceholder": "@utilisateur@instance.social",
"placeholder": "Écrivez votre message...",
"sentTo": "À",
"replyingTo": "En réponse à",
"sentYouDM": "vous a envoyé un message direct",
"viewMessage": "Voir le message",
"errorEmpty": "Le contenu du message ne peut pas être vide.",
"errorNoRecipient": "Veuillez entrer un destinataire.",
"errorRecipientNotFound": "Impossible de trouver cet utilisateur. Essayez un identifiant complet @utilisateur@domaine."
},
"reader": {
"title": "Lecteur",
"tabs": {
"all": "Tout",
"notes": "Notes",
"articles": "Articles",
"replies": "Réponses",
"boosts": "Partages",
"media": "Médias"
},
"empty": "Votre fil est vide. Suivez des comptes pour voir leurs publications ici.",
"boosted": "a partagé",
"replyingTo": "En réponse à",
"showContent": "Afficher le contenu",
"hideContent": "Masquer le contenu",
"sensitiveContent": "Contenu sensible",
"videoNotSupported": "Votre navigateur ne prend pas en charge l'élément vidéo.",
"audioNotSupported": "Votre navigateur ne prend pas en charge l'élément audio.",
"actions": {
"reply": "Répondre",
"boost": "Partager",
"unboost": "Annuler le partage",
"like": "J'aime",
"unlike": "Annuler j'aime",
"viewOriginal": "Voir l'original",
"liked": "Aimé",
"boosted": "Partagé",
"likeError": "Impossible d'aimer cette publication",
"boostError": "Impossible de partager cette publication"
},
"post": {
"title": "Détail de la publication",
"notFound": "Publication introuvable ou plus disponible.",
"openExternal": "Ouvrir sur l'instance d'origine",
"parentPosts": "Fil",
"replies": "Réponses",
"back": "Retour au fil",
"loadingThread": "Chargement du fil...",
"threadError": "Impossible de charger le fil complet"
},
"resolve": {
"placeholder": "Collez une URL du fediverse ou un identifiant @utilisateur@domaine…",
"label": "Rechercher une publication ou un compte du fediverse",
"button": "Rechercher",
"notFoundTitle": "Introuvable",
"notFound": "Impossible de trouver cette publication ou ce compte. L'URL est peut-être invalide, le serveur indisponible ou le contenu a été supprimé.",
"followersLabel": "abonnés"
},
"linkPreview": {
"label": "Aperçu du lien"
},
"explore": {
"title": "Explorer",
"description": "Parcourez les fils publics d'instances distantes compatibles Mastodon.",
"instancePlaceholder": "Entrez un nom d'hôte d'instance, ex. mastodon.social",
"browse": "Parcourir",
"local": "Local",
"federated": "Fédéré",
"loadError": "Impossible de charger le fil de cette instance. Elle est peut-être indisponible ou ne prend pas en charge l'API Mastodon.",
"timeout": "La requête a expiré. L'instance est peut-être lente ou indisponible.",
"noResults": "Aucune publication trouvée sur le fil public de cette instance.",
"invalidInstance": "Nom d'hôte d'instance invalide. Veuillez entrer un nom de domaine valide.",
"mauLabel": "MAU",
"timelineSupported": "Fil public disponible",
"timelineUnsupported": "Fil public non disponible",
"hashtagLabel": "Hashtag (optionnel)",
"hashtagPlaceholder": "ex. indieweb",
"hashtagHint": "Filtrer les résultats par un hashtag spécifique",
"tabs": {
"label": "Onglets d'exploration",
"search": "Rechercher",
"pinAsTab": "Épingler comme onglet",
"pinned": "Épinglés",
"remove": "Supprimer l'onglet",
"moveUp": "Monter",
"moveDown": "Descendre",
"addHashtag": "Ajouter un onglet hashtag",
"hashtagTabPlaceholder": "Entrez un hashtag",
"addTab": "Ajouter",
"retry": "Réessayer",
"noInstances": "Épinglez d'abord des instances pour utiliser les onglets hashtag.",
"sources": "Recherche de #%s sur %d instance",
"sources_plural": "Recherche de #%s sur %d instances",
"sourcesPartial": "%d sur %d instances ont répondu"
}
},
"tagTimeline": {
"postsTagged": "%d publication",
"postsTagged_plural": "%d publications",
"noPosts": "Aucune publication avec #%s dans votre fil.",
"followTag": "Suivre le hashtag",
"unfollowTag": "Ne plus suivre le hashtag",
"following": "Suivi"
},
"pagination": {
"newer": "← Plus récentes",
"older": "Plus anciennes →",
"loadMore": "Charger plus",
"loading": "Chargement…",
"noMore": "Vous êtes à jour."
}
},
"myProfile": {
"title": "Mon profil",
"posts": "publications",
"editProfile": "Modifier le profil",
"empty": "Rien ici pour l'instant.",
"tabs": {
"posts": "Publications",
"replies": "Réponses",
"likes": "J'aime",
"boosts": "Partages"
}
},
"poll": {
"voters": "votants",
"votes": "votes",
"closed": "Sondage clos",
"endsAt": "Se termine"
},
"federation": {
"deleteSuccess": "Activité de suppression envoyée aux abonnés",
"deleteButton": "Supprimer du fediverse"
},
"federationMgmt": {
"title": "Fédération",
"collections": "État des collections",
"quickActions": "Actions rapides",
"broadcastActor": "Diffuser la mise à jour de l'acteur",
"debugDashboard": "Tableau de bord de débogage",
"objectLookup": "Recherche d'objet",
"lookupPlaceholder": "URL ou identifiant @utilisateur@domaine…",
"lookup": "Rechercher",
"lookupLoading": "Résolution…",
"postActions": "Fédération des publications",
"viewJson": "JSON",
"rebroadcast": "Re-diffuser l'activité Create",
"rebroadcastShort": "Renvoyer",
"broadcastDelete": "Diffuser l'activité Delete",
"deleteShort": "Supprimer",
"noPosts": "Aucune publication trouvée.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Activité récente",
"viewAllActivities": "Voir toutes les activités →"
},
"reports": {
"sentReport": "a déposé un signalement",
"title": "Signalements"
}
}
}

358
locales/hi.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "अनुयायी",
"following": "अनुसरण",
"activities": "गतिविधि लॉग",
"featured": "पिन किए गए पोस्ट",
"featuredTags": "विशेष टैग",
"recentActivity": "हालिया गतिविधि",
"noActivity": "अभी तक कोई गतिविधि नहीं। जब आपका अभिनेता फ़ेडरेट हो जाएगा, तो इंटरैक्शन यहाँ दिखाई देंगे।",
"noFollowers": "अभी तक कोई अनुयायी नहीं।",
"noFollowing": "अभी तक किसी को अनुसरण नहीं कर रहे।",
"pendingFollows": "लंबित",
"noPendingFollows": "कोई लंबित अनुसरण अनुरोध नहीं।",
"approve": "स्वीकृत करें",
"reject": "अस्वीकार करें",
"followApproved": "अनुसरण अनुरोध स्वीकृत।",
"followRejected": "अनुसरण अनुरोध अस्वीकृत।",
"followRequest": "ने आपको अनुसरण करने का अनुरोध किया",
"followerCount": "%d अनुयायी",
"followerCount_plural": "%d अनुयायी",
"followingCount": "%d अनुसरण",
"followedAt": "अनुसरण",
"source": "स्रोत",
"sourceImport": "Mastodon आयात",
"sourceManual": "मैनुअल",
"sourceFederation": "फ़ेडरेशन",
"sourceRefollowPending": "पुन: अनुसरण लंबित",
"sourceRefollowFailed": "पुन: अनुसरण विफल",
"direction": "दिशा",
"directionInbound": "प्राप्त",
"directionOutbound": "भेजा गया",
"profile": {
"title": "प्रोफ़ाइल",
"intro": "fediverse के अन्य उपयोगकर्ताओं को आपका अभिनेता कैसा दिखे, यह संपादित करें। परिवर्तन तुरंत प्रभावी होते हैं।",
"nameLabel": "प्रदर्शित नाम",
"nameHint": "आपका नाम जैसा कि आपकी fediverse प्रोफ़ाइल पर दिखाया गया है",
"summaryLabel": "परिचय",
"summaryHint": "अपने बारे में एक संक्षिप्त विवरण। HTML अनुमत है।",
"urlLabel": "वेबसाइट URL",
"urlHint": "आपकी वेबसाइट का पता, आपकी प्रोफ़ाइल पर लिंक के रूप में दिखाया गया",
"iconLabel": "अवतार URL",
"iconHint": "आपकी प्रोफ़ाइल तस्वीर का URL (वर्गाकार, कम से कम 400x400px अनुशंसित)",
"imageLabel": "हेडर छवि URL",
"imageHint": "आपकी प्रोफ़ाइल के शीर्ष पर दिखाई जाने वाली बैनर छवि का URL",
"manualApprovalLabel": "अनुयायियों को मैन्युअल रूप से स्वीकृत करें",
"manualApprovalHint": "सक्षम होने पर, अनुसरण अनुरोधों को प्रभावी होने से पहले आपकी स्वीकृति की आवश्यकता होती है",
"actorTypeLabel": "अभिनेता प्रकार",
"actorTypeHint": "fediverse में आपका खाता कैसे दिखाई देता है। व्यक्तियों के लिए Person, बॉट या स्वचालित खातों के लिए Service, समूहों या कंपनियों के लिए Organization।",
"linksLabel": "प्रोफ़ाइल लिंक",
"linksHint": "आपकी fediverse प्रोफ़ाइल पर दिखाए गए लिंक। अपनी वेबसाइट, सोशल खाते, या अन्य URL जोड़ें। rel=\"me\" के साथ वापस लिंक करने वाले पृष्ठ Mastodon पर सत्यापित के रूप में दिखाई देंगे।",
"linkNameLabel": "लेबल",
"linkValueLabel": "URL",
"addLink": "लिंक जोड़ें",
"removeLink": "हटाएँ",
"authorizedFetchLabel": "अधिकृत फ़ेच आवश्यक करें (सुरक्षित मोड)",
"authorizedFetchHint": "सक्षम होने पर, केवल वैध HTTP हस्ताक्षर वाले सर्वर आपके अभिनेता और संग्रह प्राप्त कर सकते हैं। यह गोपनीयता बढ़ाता है लेकिन कुछ क्लाइंट के साथ संगतता कम कर सकता है।",
"save": "प्रोफ़ाइल सहेजें",
"saved": "प्रोफ़ाइल सहेजी गई। परिवर्तन अब fediverse में दिखाई दे रहे हैं।",
"public": {
"followPrompt": "fediverse पर मुझे फ़ॉलो करें",
"copyHandle": "हैंडल कॉपी करें",
"copied": "कॉपी किया!",
"pinnedPosts": "पिन किए गए पोस्ट",
"recentPosts": "हालिया पोस्ट",
"joinedDate": "शामिल हुए",
"posts": "पोस्ट",
"followers": "अनुयायी",
"following": "अनुसरण",
"viewOnSite": "साइट पर देखें"
},
"remote": {
"follow": "अनुसरण करें",
"unfollow": "अनुसरण बंद करें",
"viewOn": "यहाँ देखें",
"postsTitle": "पोस्ट",
"noPosts": "इस खाते से अभी तक कोई पोस्ट नहीं।",
"followToSee": "उनके पोस्ट अपनी टाइमलाइन में देखने के लिए इस खाते को अनुसरण करें।",
"notFound": "यह खाता नहीं मिला। हो सकता है कि इसे हटा दिया गया हो या सर्वर अनुपलब्ध हो।"
}
},
"migrate": {
"title": "Mastodon माइग्रेशन",
"intro": "यह मार्गदर्शिका आपकी Mastodon पहचान को आपकी IndieWeb साइट पर ले जाने में मदद करती है। प्रत्येक चरण क्रम में पूरा करें — आपके मौजूदा अनुयायियों को सूचित किया जाएगा और वे स्वचालित रूप से आपको पुनः अनुसरण कर सकते हैं।",
"step1Title": "चरण 1 — अपना पुराना खाता लिंक करें",
"step1Desc": "fediverse को बताएँ कि आपका पुराना Mastodon खाता और यह साइट एक ही व्यक्ति की है। यह आपके ActivityPub अभिनेता पर <code>alsoKnownAs</code> गुण सेट करता है, जिसे Mastodon मूव की अनुमति देने से पहले जाँचता है।",
"aliasLabel": "पुराने Mastodon खाते का URL",
"aliasHint": "आपकी Mastodon प्रोफ़ाइल का पूरा URL, उदा. https://mstdn.social/users/rmdes",
"aliasSave": "उपनाम सहेजें",
"aliasCurrent": "वर्तमान उपनाम",
"aliasNone": "अभी तक कोई उपनाम कॉन्फ़िगर नहीं किया गया।",
"step2Title": "चरण 2 — अपना सोशल ग्राफ़ आयात करें",
"step2Desc": "अपने कनेक्शन लाने के लिए अपने Mastodon डेटा एक्सपोर्ट से CSV फ़ाइलें अपलोड करें। उन्हें डाउनलोड करने के लिए अपनी Mastodon इंस्टेंस → प्राथमिकताएँ → आयात और निर्यात → डेटा निर्यात पर जाएँ।",
"importLegend": "क्या आयात करें",
"fileLabel": "CSV फ़ाइल",
"fileHint": "अपने Mastodon डेटा एक्सपोर्ट से एक CSV फ़ाइल चुनें (उदा. following_accounts.csv या followers.csv)",
"importButton": "आयात करें",
"importFollowing": "अनुसरण सूची",
"importFollowingHint": "जिन खातों को आप अनुसरण करते हैं — वे तुरंत आपकी अनुसरण सूची में दिखाई देंगे",
"importFollowers": "अनुयायी सूची",
"importFollowersHint": "आपके वर्तमान अनुयायी — चरण 3 में मूव के बाद वे पुनः अनुसरण करने तक लंबित के रूप में दर्ज किए जाएंगे",
"step3Title": "चरण 3 — अपना खाता स्थानांतरित करें",
"step3Desc": "एक बार जब आपने अपना उपनाम सहेज लिया और अपना डेटा आयात कर लिया, तो अपनी Mastodon इंस्टेंस → प्राथमिकताएँ → खाता → <strong>किसी अन्य खाते में स्थानांतरित करें</strong> पर जाएँ। अपना नया fediverse हैंडल दर्ज करें और पुष्टि करें। Mastodon आपके सभी अनुयायियों को सूचित करेगा, और जिनके सर्वर इसका समर्थन करते हैं वे स्वचालित रूप से आपको यहाँ पुनः अनुसरण करेंगे। यह चरण अपरिवर्तनीय है — आपका पुराना खाता एक रीडायरेक्ट बन जाएगा।",
"errorNoFile": "कृपया आयात करने से पहले एक CSV फ़ाइल चुनें।",
"success": "%d अनुसरण, %d अनुयायी आयात किए (%d विफल)।",
"failedList": "हल नहीं कर सके: %s",
"failedListSummary": "विफल हैंडल",
"aliasSuccess": "उपनाम सहेजा — आपके अभिनेता दस्तावेज़ में अब यह खाता alsoKnownAs के रूप में शामिल है।"
},
"refollow": {
"title": "बैच पुन: अनुसरण",
"progress": "पुन: अनुसरण प्रगति",
"remaining": "शेष",
"awaitingAccept": "स्वीकृति की प्रतीक्षा",
"accepted": "स्वीकृत",
"failed": "विफल",
"pause": "रोकें",
"resume": "जारी रखें",
"status": {
"idle": "निष्क्रिय",
"running": "चल रहा है",
"paused": "रुका हुआ",
"completed": "पूर्ण"
}
},
"moderation": {
"title": "मॉडरेशन",
"blockedTitle": "अवरुद्ध खाते",
"mutedActorsTitle": "म्यूट किए गए खाते",
"mutedKeywordsTitle": "म्यूट किए गए कीवर्ड",
"noBlocked": "कोई अवरुद्ध खाते नहीं।",
"noMutedActors": "कोई म्यूट किए गए खाते नहीं।",
"noMutedKeywords": "कोई म्यूट किए गए कीवर्ड नहीं।",
"unblock": "अनब्लॉक करें",
"unmute": "अनम्यूट करें",
"addKeywordTitle": "म्यूट किया गया कीवर्ड जोड़ें",
"keywordPlaceholder": "कीवर्ड या वाक्यांश दर्ज करें…",
"addKeyword": "जोड़ें",
"muteActor": "म्यूट करें",
"blockActor": "ब्लॉक करें",
"filterModeTitle": "फ़िल्टर मोड",
"filterModeHint": "चुनें कि आपकी टाइमलाइन में म्यूट की गई सामग्री कैसे प्रबंधित की जाती है। अवरुद्ध खाते हमेशा छिपे रहते हैं।",
"filterModeHide": "छुपाएँ — टाइमलाइन से हटाएँ",
"filterModeWarn": "चेतावनी — सामग्री चेतावनी के पीछे दिखाएँ",
"cwMutedAccount": "म्यूट किया गया खाता",
"cwMutedKeyword": "म्यूट किया गया कीवर्ड:",
"cwFiltered": "फ़िल्टर की गई सामग्री"
},
"compose": {
"title": "उत्तर लिखें",
"placeholder": "अपना उत्तर लिखें…",
"syndicateLabel": "सिंडिकेट करें",
"submitMicropub": "उत्तर पोस्ट करें",
"cancel": "रद्द करें",
"errorEmpty": "उत्तर की सामग्री खाली नहीं हो सकती",
"visibilityLabel": "दृश्यता",
"visibilityPublic": "सार्वजनिक",
"visibilityUnlisted": "असूचीबद्ध",
"visibilityFollowers": "केवल अनुयायी",
"cwLabel": "सामग्री चेतावनी",
"cwPlaceholder": "अपनी चेतावनी यहाँ लिखें…"
},
"notifications": {
"title": "सूचनाएँ",
"empty": "अभी तक कोई सूचना नहीं। अन्य fediverse उपयोगकर्ताओं से इंटरैक्शन यहाँ दिखाई देंगे।",
"liked": "ने आपके पोस्ट को पसंद किया",
"boostedPost": "ने आपके पोस्ट को बूस्ट किया",
"followedYou": "ने आपको अनुसरण किया",
"repliedTo": "ने आपके पोस्ट का उत्तर दिया",
"mentionedYou": "ने आपका उल्लेख किया",
"markAllRead": "सभी को पढ़ा हुआ चिह्नित करें",
"clearAll": "सभी साफ़ करें",
"clearConfirm": "सभी सूचनाएँ हटाएँ? यह पूर्ववत नहीं किया जा सकता।",
"dismiss": "खारिज करें",
"viewThread": "थ्रेड देखें",
"tabs": {
"all": "सभी",
"replies": "उत्तर",
"likes": "पसंद",
"boosts": "बूस्ट",
"follows": "अनुसरण",
"dms": "DMs",
"reports": "रिपोर्ट"
},
"emptyTab": "अभी तक कोई %s सूचनाएँ नहीं।"
},
"messages": {
"title": "संदेश",
"empty": "अभी तक कोई संदेश नहीं। अन्य fediverse उपयोगकर्ताओं से सीधे संदेश यहाँ दिखाई देंगे।",
"allConversations": "सभी बातचीत",
"compose": "नया संदेश",
"send": "संदेश भेजें",
"delete": "हटाएँ",
"markAllRead": "सभी को पढ़ा हुआ चिह्नित करें",
"clearAll": "सभी साफ़ करें",
"clearConfirm": "सभी संदेश हटाएँ? यह पूर्ववत नहीं किया जा सकता।",
"recipientLabel": "प्रति",
"recipientPlaceholder": "@उपयोगकर्ता@इंस्टेंस.social",
"placeholder": "अपना संदेश लिखें...",
"sentTo": "प्रति",
"replyingTo": "उत्तर दे रहे हैं",
"sentYouDM": "ने आपको एक सीधा संदेश भेजा",
"viewMessage": "संदेश देखें",
"errorEmpty": "संदेश सामग्री खाली नहीं हो सकती।",
"errorNoRecipient": "कृपया एक प्राप्तकर्ता दर्ज करें।",
"errorRecipientNotFound": "वह उपयोगकर्ता नहीं मिला। पूर्ण @उपयोगकर्ता@डोमेन हैंडल आज़माएँ।"
},
"reader": {
"title": "रीडर",
"tabs": {
"all": "सभी",
"notes": "नोट्स",
"articles": "लेख",
"replies": "उत्तर",
"boosts": "बूस्ट",
"media": "मीडिया"
},
"empty": "आपकी टाइमलाइन खाली है। उनके पोस्ट यहाँ देखने के लिए कुछ खातों को अनुसरण करें।",
"boosted": "बूस्ट किया",
"replyingTo": "उत्तर दे रहे हैं",
"showContent": "सामग्री दिखाएँ",
"hideContent": "सामग्री छिपाएँ",
"sensitiveContent": "संवेदनशील सामग्री",
"videoNotSupported": "आपका ब्राउज़र वीडियो तत्व का समर्थन नहीं करता।",
"audioNotSupported": "आपका ब्राउज़र ऑडियो तत्व का समर्थन नहीं करता।",
"actions": {
"reply": "उत्तर दें",
"boost": "बूस्ट",
"unboost": "बूस्ट वापस लें",
"like": "पसंद",
"unlike": "पसंद वापस लें",
"viewOriginal": "मूल देखें",
"liked": "पसंद किया",
"boosted": "बूस्ट किया",
"likeError": "इस पोस्ट को पसंद नहीं किया जा सका",
"boostError": "इस पोस्ट को बूस्ट नहीं किया जा सका"
},
"post": {
"title": "पोस्ट विवरण",
"notFound": "पोस्ट नहीं मिला या अब उपलब्ध नहीं।",
"openExternal": "मूल इंस्टेंस पर खोलें",
"parentPosts": "थ्रेड",
"replies": "उत्तर",
"back": "टाइमलाइन पर वापस",
"loadingThread": "थ्रेड लोड हो रहा है...",
"threadError": "पूरा थ्रेड लोड नहीं हो सका"
},
"resolve": {
"placeholder": "कोई fediverse URL या @उपयोगकर्ता@डोमेन हैंडल पेस्ट करें…",
"label": "fediverse पोस्ट या खाता खोजें",
"button": "खोजें",
"notFoundTitle": "नहीं मिला",
"notFound": "यह पोस्ट या खाता नहीं मिला। URL अमान्य हो सकता है, सर्वर अनुपलब्ध हो सकता है, या सामग्री हटा दी गई हो सकती है।",
"followersLabel": "अनुयायी"
},
"linkPreview": {
"label": "लिंक पूर्वावलोकन"
},
"explore": {
"title": "एक्सप्लोर",
"description": "दूरस्थ Mastodon-संगत इंस्टेंस से सार्वजनिक टाइमलाइन ब्राउज़ करें।",
"instancePlaceholder": "एक इंस्टेंस होस्टनाम दर्ज करें, उदा. mastodon.social",
"browse": "ब्राउज़ करें",
"local": "स्थानीय",
"federated": "फ़ेडरेटेड",
"loadError": "इस इंस्टेंस से टाइमलाइन लोड नहीं हो सकी। यह अनुपलब्ध हो सकता है या Mastodon API का समर्थन नहीं कर सकता।",
"timeout": "अनुरोध का समय समाप्त हो गया। इंस्टेंस धीमा या अनुपलब्ध हो सकता है।",
"noResults": "इस इंस्टेंस की सार्वजनिक टाइमलाइन पर कोई पोस्ट नहीं मिले।",
"invalidInstance": "अमान्य इंस्टेंस होस्टनाम। कृपया एक मान्य डोमेन नाम दर्ज करें।",
"mauLabel": "MAU",
"timelineSupported": "सार्वजनिक टाइमलाइन उपलब्ध",
"timelineUnsupported": "सार्वजनिक टाइमलाइन उपलब्ध नहीं",
"hashtagLabel": "हैशटैग (वैकल्पिक)",
"hashtagPlaceholder": "उदा. indieweb",
"hashtagHint": "किसी विशिष्ट हैशटैग द्वारा परिणाम फ़िल्टर करें",
"tabs": {
"label": "एक्सप्लोर टैब",
"search": "खोजें",
"pinAsTab": "टैब के रूप में पिन करें",
"pinned": "पिन किए गए",
"remove": "टैब हटाएँ",
"moveUp": "ऊपर ले जाएँ",
"moveDown": "नीचे ले जाएँ",
"addHashtag": "हैशटैग टैब जोड़ें",
"hashtagTabPlaceholder": "हैशटैग दर्ज करें",
"addTab": "जोड़ें",
"retry": "पुनः प्रयास करें",
"noInstances": "हैशटैग टैब का उपयोग करने के लिए पहले कुछ इंस्टेंस पिन करें।",
"sources": "%d इंस्टेंस पर #%s खोज रहे हैं",
"sources_plural": "%d इंस्टेंस पर #%s खोज रहे हैं",
"sourcesPartial": "%d में से %d इंस्टेंस ने जवाब दिया"
}
},
"tagTimeline": {
"postsTagged": "%d पोस्ट",
"postsTagged_plural": "%d पोस्ट",
"noPosts": "आपकी टाइमलाइन में #%s वाले कोई पोस्ट नहीं मिले।",
"followTag": "हैशटैग अनुसरण करें",
"unfollowTag": "हैशटैग अनुसरण बंद करें",
"following": "अनुसरण कर रहे हैं"
},
"pagination": {
"newer": "← नए",
"older": "पुराने →",
"loadMore": "और लोड करें",
"loading": "लोड हो रहा है…",
"noMore": "आप अप टू डेट हैं।"
}
},
"myProfile": {
"title": "मेरी प्रोफ़ाइल",
"posts": "पोस्ट",
"editProfile": "प्रोफ़ाइल संपादित करें",
"empty": "यहाँ अभी कुछ नहीं है।",
"tabs": {
"posts": "पोस्ट",
"replies": "उत्तर",
"likes": "पसंद",
"boosts": "बूस्ट"
}
},
"poll": {
"voters": "मतदाता",
"votes": "वोट",
"closed": "मतदान बंद",
"endsAt": "समाप्ति"
},
"federation": {
"deleteSuccess": "अनुयायियों को हटाने की गतिविधि भेजी गई",
"deleteButton": "fediverse से हटाएँ"
},
"federationMgmt": {
"title": "फ़ेडरेशन",
"collections": "संग्रह स्वास्थ्य",
"quickActions": "त्वरित कार्रवाइयाँ",
"broadcastActor": "अभिनेता अपडेट प्रसारित करें",
"debugDashboard": "डीबग डैशबोर्ड",
"objectLookup": "ऑब्जेक्ट खोज",
"lookupPlaceholder": "URL या @उपयोगकर्ता@डोमेन हैंडल…",
"lookup": "खोजें",
"lookupLoading": "हल कर रहे हैं…",
"postActions": "पोस्ट फ़ेडरेशन",
"viewJson": "JSON",
"rebroadcast": "Create गतिविधि पुनः प्रसारित करें",
"rebroadcastShort": "पुनः भेजें",
"broadcastDelete": "Delete गतिविधि प्रसारित करें",
"deleteShort": "हटाएँ",
"noPosts": "कोई पोस्ट नहीं मिले।",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "हालिया गतिविधि",
"viewAllActivities": "सभी गतिविधियाँ देखें →"
},
"reports": {
"sentReport": "ने एक रिपोर्ट दर्ज की",
"title": "रिपोर्ट"
}
}
}

358
locales/id.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Pengikut",
"following": "Mengikuti",
"activities": "Log aktivitas",
"featured": "Pos yang disematkan",
"featuredTags": "Tag unggulan",
"recentActivity": "Aktivitas terbaru",
"noActivity": "Belum ada aktivitas. Setelah aktor Anda terfederasi, interaksi akan muncul di sini.",
"noFollowers": "Belum ada pengikut.",
"noFollowing": "Belum mengikuti siapa pun.",
"pendingFollows": "Tertunda",
"noPendingFollows": "Tidak ada permintaan mengikuti yang tertunda.",
"approve": "Setujui",
"reject": "Tolak",
"followApproved": "Permintaan mengikuti disetujui.",
"followRejected": "Permintaan mengikuti ditolak.",
"followRequest": "meminta untuk mengikuti Anda",
"followerCount": "%d pengikut",
"followerCount_plural": "%d pengikut",
"followingCount": "%d mengikuti",
"followedAt": "Mengikuti sejak",
"source": "Sumber",
"sourceImport": "Impor Mastodon",
"sourceManual": "Manual",
"sourceFederation": "Federasi",
"sourceRefollowPending": "Ikuti ulang tertunda",
"sourceRefollowFailed": "Ikuti ulang gagal",
"direction": "Arah",
"directionInbound": "Diterima",
"directionOutbound": "Dikirim",
"profile": {
"title": "Profil",
"intro": "Edit tampilan aktor Anda bagi pengguna fediverse lainnya. Perubahan langsung berlaku.",
"nameLabel": "Nama tampilan",
"nameHint": "Nama Anda seperti yang ditampilkan di profil fediverse Anda",
"summaryLabel": "Bio",
"summaryHint": "Deskripsi singkat tentang diri Anda. HTML diperbolehkan.",
"urlLabel": "URL situs web",
"urlHint": "Alamat situs web Anda, ditampilkan sebagai tautan di profil Anda",
"iconLabel": "URL avatar",
"iconHint": "URL gambar profil Anda (persegi, minimal 400x400px disarankan)",
"imageLabel": "URL gambar header",
"imageHint": "URL gambar banner yang ditampilkan di bagian atas profil Anda",
"manualApprovalLabel": "Setujui pengikut secara manual",
"manualApprovalHint": "Jika diaktifkan, permintaan mengikuti memerlukan persetujuan Anda sebelum berlaku",
"actorTypeLabel": "Jenis aktor",
"actorTypeHint": "Bagaimana akun Anda muncul di fediverse. Person untuk individu, Service untuk bot atau akun otomatis, Organization untuk grup atau perusahaan.",
"linksLabel": "Tautan profil",
"linksHint": "Tautan yang ditampilkan di profil fediverse Anda. Tambahkan situs web, akun sosial, atau URL lainnya. Halaman yang menautkan balik dengan rel=\"me\" akan ditampilkan sebagai terverifikasi di Mastodon.",
"linkNameLabel": "Label",
"linkValueLabel": "URL",
"addLink": "Tambah tautan",
"removeLink": "Hapus",
"authorizedFetchLabel": "Wajibkan pengambilan terotorisasi (mode aman)",
"authorizedFetchHint": "Jika diaktifkan, hanya server dengan Tanda Tangan HTTP yang valid yang dapat mengambil aktor dan koleksi Anda. Ini meningkatkan privasi tetapi mungkin mengurangi kompatibilitas dengan beberapa klien.",
"save": "Simpan profil",
"saved": "Profil disimpan. Perubahan sekarang terlihat di fediverse.",
"public": {
"followPrompt": "Ikuti saya di fediverse",
"copyHandle": "Salin handle",
"copied": "Disalin!",
"pinnedPosts": "Pos yang disematkan",
"recentPosts": "Pos terbaru",
"joinedDate": "Bergabung",
"posts": "Pos",
"followers": "Pengikut",
"following": "Mengikuti",
"viewOnSite": "Lihat di situs"
},
"remote": {
"follow": "Ikuti",
"unfollow": "Berhenti mengikuti",
"viewOn": "Lihat di",
"postsTitle": "Pos",
"noPosts": "Belum ada pos dari akun ini.",
"followToSee": "Ikuti akun ini untuk melihat pos mereka di linimasa Anda.",
"notFound": "Tidak dapat menemukan akun ini. Mungkin telah dihapus atau server tidak tersedia."
}
},
"migrate": {
"title": "Migrasi Mastodon",
"intro": "Panduan ini memandu Anda memindahkan identitas Mastodon ke situs IndieWeb Anda. Selesaikan setiap langkah secara berurutan — pengikut Anda yang ada akan diberitahu dan dapat mengikuti Anda kembali secara otomatis.",
"step1Title": "Langkah 1 — Tautkan akun lama Anda",
"step1Desc": "Beritahu fediverse bahwa akun Mastodon lama Anda dan situs ini milik orang yang sama. Ini menetapkan properti <code>alsoKnownAs</code> pada aktor ActivityPub Anda, yang diperiksa Mastodon sebelum mengizinkan Pemindahan.",
"aliasLabel": "URL akun Mastodon lama",
"aliasHint": "URL lengkap profil Mastodon Anda, mis. https://mstdn.social/users/rmdes",
"aliasSave": "Simpan alias",
"aliasCurrent": "Alias saat ini",
"aliasNone": "Belum ada alias yang dikonfigurasi.",
"step2Title": "Langkah 2 — Impor jaringan sosial Anda",
"step2Desc": "Unggah file CSV dari ekspor data Mastodon Anda untuk membawa koneksi Anda. Buka instans Mastodon Anda → Preferensi → Impor dan ekspor → Ekspor data untuk mengunduhnya.",
"importLegend": "Apa yang diimpor",
"fileLabel": "File CSV",
"fileHint": "Pilih file CSV dari ekspor data Mastodon Anda (mis. following_accounts.csv atau followers.csv)",
"importButton": "Impor",
"importFollowing": "Daftar mengikuti",
"importFollowingHint": "Akun yang Anda ikuti — mereka akan langsung muncul di daftar Mengikuti Anda",
"importFollowers": "Daftar pengikut",
"importFollowersHint": "Pengikut Anda saat ini — mereka akan dicatat sebagai tertunda sampai mereka mengikuti Anda kembali setelah Pemindahan di langkah 3",
"step3Title": "Langkah 3 — Pindahkan akun Anda",
"step3Desc": "Setelah Anda menyimpan alias dan mengimpor data Anda, buka instans Mastodon Anda → Preferensi → Akun → <strong>Pindah ke akun lain</strong>. Masukkan handle fediverse baru Anda dan konfirmasi. Mastodon akan memberitahu semua pengikut Anda, dan mereka yang servernya mendukung akan mengikuti Anda secara otomatis di sini. Langkah ini tidak dapat dibatalkan — akun lama Anda akan menjadi pengalihan.",
"errorNoFile": "Silakan pilih file CSV sebelum mengimpor.",
"success": "Diimpor %d mengikuti, %d pengikut (%d gagal).",
"failedList": "Tidak dapat diselesaikan: %s",
"failedListSummary": "Handle yang gagal",
"aliasSuccess": "Alias disimpan — dokumen aktor Anda sekarang menyertakan akun ini sebagai alsoKnownAs."
},
"refollow": {
"title": "Ikuti ulang massal",
"progress": "Progres ikuti ulang",
"remaining": "Tersisa",
"awaitingAccept": "Menunggu penerimaan",
"accepted": "Diterima",
"failed": "Gagal",
"pause": "Jeda",
"resume": "Lanjutkan",
"status": {
"idle": "Tidak aktif",
"running": "Berjalan",
"paused": "Dijeda",
"completed": "Selesai"
}
},
"moderation": {
"title": "Moderasi",
"blockedTitle": "Akun yang diblokir",
"mutedActorsTitle": "Akun yang dibisukan",
"mutedKeywordsTitle": "Kata kunci yang dibisukan",
"noBlocked": "Tidak ada akun yang diblokir.",
"noMutedActors": "Tidak ada akun yang dibisukan.",
"noMutedKeywords": "Tidak ada kata kunci yang dibisukan.",
"unblock": "Buka blokir",
"unmute": "Bunyikan",
"addKeywordTitle": "Tambah kata kunci yang dibisukan",
"keywordPlaceholder": "Masukkan kata kunci atau frasa…",
"addKeyword": "Tambah",
"muteActor": "Bisukan",
"blockActor": "Blokir",
"filterModeTitle": "Mode filter",
"filterModeHint": "Pilih bagaimana konten yang dibisukan ditangani di linimasa Anda. Akun yang diblokir selalu disembunyikan.",
"filterModeHide": "Sembunyikan — hapus dari linimasa",
"filterModeWarn": "Peringatan — tampilkan di balik peringatan konten",
"cwMutedAccount": "Akun yang dibisukan",
"cwMutedKeyword": "Kata kunci yang dibisukan:",
"cwFiltered": "Konten yang difilter"
},
"compose": {
"title": "Tulis balasan",
"placeholder": "Tulis balasan Anda…",
"syndicateLabel": "Sindikasikan ke",
"submitMicropub": "Kirim balasan",
"cancel": "Batal",
"errorEmpty": "Konten balasan tidak boleh kosong",
"visibilityLabel": "Visibilitas",
"visibilityPublic": "Publik",
"visibilityUnlisted": "Tidak terdaftar",
"visibilityFollowers": "Pengikut saja",
"cwLabel": "Peringatan konten",
"cwPlaceholder": "Tulis peringatan Anda di sini…"
},
"notifications": {
"title": "Notifikasi",
"empty": "Belum ada notifikasi. Interaksi dari pengguna fediverse lainnya akan muncul di sini.",
"liked": "menyukai pos Anda",
"boostedPost": "mem-boost pos Anda",
"followedYou": "mengikuti Anda",
"repliedTo": "membalas pos Anda",
"mentionedYou": "menyebut Anda",
"markAllRead": "Tandai semua sudah dibaca",
"clearAll": "Hapus semua",
"clearConfirm": "Hapus semua notifikasi? Ini tidak dapat dibatalkan.",
"dismiss": "Abaikan",
"viewThread": "Lihat utas",
"tabs": {
"all": "Semua",
"replies": "Balasan",
"likes": "Suka",
"boosts": "Boost",
"follows": "Mengikuti",
"dms": "DM",
"reports": "Laporan"
},
"emptyTab": "Belum ada notifikasi %s."
},
"messages": {
"title": "Pesan",
"empty": "Belum ada pesan. Pesan langsung dari pengguna fediverse lainnya akan muncul di sini.",
"allConversations": "Semua percakapan",
"compose": "Pesan baru",
"send": "Kirim pesan",
"delete": "Hapus",
"markAllRead": "Tandai semua sudah dibaca",
"clearAll": "Hapus semua",
"clearConfirm": "Hapus semua pesan? Ini tidak dapat dibatalkan.",
"recipientLabel": "Kepada",
"recipientPlaceholder": "@pengguna@instans.social",
"placeholder": "Tulis pesan Anda...",
"sentTo": "Kepada",
"replyingTo": "Membalas ke",
"sentYouDM": "mengirimi Anda pesan langsung",
"viewMessage": "Lihat pesan",
"errorEmpty": "Konten pesan tidak boleh kosong.",
"errorNoRecipient": "Silakan masukkan penerima.",
"errorRecipientNotFound": "Tidak dapat menemukan pengguna tersebut. Coba handle lengkap @pengguna@domain."
},
"reader": {
"title": "Pembaca",
"tabs": {
"all": "Semua",
"notes": "Catatan",
"articles": "Artikel",
"replies": "Balasan",
"boosts": "Boost",
"media": "Media"
},
"empty": "Linimasa Anda kosong. Ikuti beberapa akun untuk melihat pos mereka di sini.",
"boosted": "mem-boost",
"replyingTo": "Membalas ke",
"showContent": "Tampilkan konten",
"hideContent": "Sembunyikan konten",
"sensitiveContent": "Konten sensitif",
"videoNotSupported": "Browser Anda tidak mendukung elemen video.",
"audioNotSupported": "Browser Anda tidak mendukung elemen audio.",
"actions": {
"reply": "Balas",
"boost": "Boost",
"unboost": "Batalkan boost",
"like": "Suka",
"unlike": "Batalkan suka",
"viewOriginal": "Lihat asli",
"liked": "Disukai",
"boosted": "Di-boost",
"likeError": "Tidak dapat menyukai pos ini",
"boostError": "Tidak dapat mem-boost pos ini"
},
"post": {
"title": "Detail Pos",
"notFound": "Pos tidak ditemukan atau tidak lagi tersedia.",
"openExternal": "Buka di instans asli",
"parentPosts": "Utas",
"replies": "Balasan",
"back": "Kembali ke linimasa",
"loadingThread": "Memuat utas...",
"threadError": "Tidak dapat memuat utas lengkap"
},
"resolve": {
"placeholder": "Tempelkan URL fediverse atau handle @pengguna@domain…",
"label": "Cari pos atau akun fediverse",
"button": "Cari",
"notFoundTitle": "Tidak ditemukan",
"notFound": "Tidak dapat menemukan pos atau akun ini. URL mungkin tidak valid, server mungkin tidak tersedia, atau konten mungkin telah dihapus.",
"followersLabel": "pengikut"
},
"linkPreview": {
"label": "Pratinjau tautan"
},
"explore": {
"title": "Jelajahi",
"description": "Telusuri linimasa publik dari instans jarak jauh yang kompatibel dengan Mastodon.",
"instancePlaceholder": "Masukkan hostname instans, mis. mastodon.social",
"browse": "Telusuri",
"local": "Lokal",
"federated": "Terfederasi",
"loadError": "Tidak dapat memuat linimasa dari instans ini. Mungkin tidak tersedia atau tidak mendukung API Mastodon.",
"timeout": "Permintaan habis waktu. Instans mungkin lambat atau tidak tersedia.",
"noResults": "Tidak ada pos ditemukan di linimasa publik instans ini.",
"invalidInstance": "Hostname instans tidak valid. Silakan masukkan nama domain yang valid.",
"mauLabel": "MAU",
"timelineSupported": "Linimasa publik tersedia",
"timelineUnsupported": "Linimasa publik tidak tersedia",
"hashtagLabel": "Tagar (opsional)",
"hashtagPlaceholder": "mis. indieweb",
"hashtagHint": "Filter hasil berdasarkan tagar tertentu",
"tabs": {
"label": "Tab jelajahi",
"search": "Cari",
"pinAsTab": "Sematkan sebagai tab",
"pinned": "Disematkan",
"remove": "Hapus tab",
"moveUp": "Naikkan",
"moveDown": "Turunkan",
"addHashtag": "Tambah tab tagar",
"hashtagTabPlaceholder": "Masukkan tagar",
"addTab": "Tambah",
"retry": "Coba lagi",
"noInstances": "Sematkan beberapa instans terlebih dahulu untuk menggunakan tab tagar.",
"sources": "Mencari #%s di %d instans",
"sources_plural": "Mencari #%s di %d instans",
"sourcesPartial": "%d dari %d instans merespons"
}
},
"tagTimeline": {
"postsTagged": "%d pos",
"postsTagged_plural": "%d pos",
"noPosts": "Tidak ada pos dengan #%s ditemukan di linimasa Anda.",
"followTag": "Ikuti tagar",
"unfollowTag": "Berhenti mengikuti tagar",
"following": "Mengikuti"
},
"pagination": {
"newer": "← Lebih baru",
"older": "Lebih lama →",
"loadMore": "Muat lebih banyak",
"loading": "Memuat…",
"noMore": "Anda sudah melihat semuanya."
}
},
"myProfile": {
"title": "Profil Saya",
"posts": "pos",
"editProfile": "Edit profil",
"empty": "Belum ada apa-apa di sini.",
"tabs": {
"posts": "Pos",
"replies": "Balasan",
"likes": "Suka",
"boosts": "Boost"
}
},
"poll": {
"voters": "pemilih",
"votes": "suara",
"closed": "Jajak pendapat ditutup",
"endsAt": "Berakhir"
},
"federation": {
"deleteSuccess": "Aktivitas hapus dikirim ke pengikut",
"deleteButton": "Hapus dari fediverse"
},
"federationMgmt": {
"title": "Federasi",
"collections": "Kesehatan koleksi",
"quickActions": "Aksi cepat",
"broadcastActor": "Siarkan pembaruan aktor",
"debugDashboard": "Dasbor debug",
"objectLookup": "Pencarian objek",
"lookupPlaceholder": "URL atau handle @pengguna@domain…",
"lookup": "Cari",
"lookupLoading": "Menyelesaikan…",
"postActions": "Federasi pos",
"viewJson": "JSON",
"rebroadcast": "Siarkan ulang aktivitas Create",
"rebroadcastShort": "Kirim ulang",
"broadcastDelete": "Siarkan aktivitas Delete",
"deleteShort": "Hapus",
"noPosts": "Tidak ada pos ditemukan.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Aktivitas terbaru",
"viewAllActivities": "Lihat semua aktivitas →"
},
"reports": {
"sentReport": "mengajukan laporan",
"title": "Laporan"
}
}
}

358
locales/it.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Seguaci",
"following": "Seguiti",
"activities": "Registro attività",
"featured": "Post in evidenza",
"featuredTags": "Tag in evidenza",
"recentActivity": "Attività recente",
"noActivity": "Ancora nessuna attività. Una volta federato il tuo attore, le interazioni appariranno qui.",
"noFollowers": "Ancora nessun seguace.",
"noFollowing": "Non segui ancora nessuno.",
"pendingFollows": "In attesa",
"noPendingFollows": "Nessuna richiesta di seguimento in attesa.",
"approve": "Approva",
"reject": "Rifiuta",
"followApproved": "Richiesta di seguimento approvata.",
"followRejected": "Richiesta di seguimento rifiutata.",
"followRequest": "ha chiesto di seguirti",
"followerCount": "%d seguace",
"followerCount_plural": "%d seguaci",
"followingCount": "%d seguiti",
"followedAt": "Seguace dal",
"source": "Fonte",
"sourceImport": "Importazione da Mastodon",
"sourceManual": "Manuale",
"sourceFederation": "Federazione",
"sourceRefollowPending": "Ri-seguimento in attesa",
"sourceRefollowFailed": "Ri-seguimento fallito",
"direction": "Direzione",
"directionInbound": "Ricevuto",
"directionOutbound": "Inviato",
"profile": {
"title": "Profilo",
"intro": "Modifica come il tuo attore appare agli altri utenti del fediverse. Le modifiche hanno effetto immediato.",
"nameLabel": "Nome visualizzato",
"nameHint": "Il tuo nome come mostrato sul tuo profilo fediverse",
"summaryLabel": "Biografia",
"summaryHint": "Una breve descrizione di te stesso. L'HTML è consentito.",
"urlLabel": "URL del sito web",
"urlHint": "L'indirizzo del tuo sito web, mostrato come link sul tuo profilo",
"iconLabel": "URL dell'avatar",
"iconHint": "URL della tua immagine del profilo (quadrata, almeno 400x400px consigliati)",
"imageLabel": "URL dell'immagine di intestazione",
"imageHint": "URL di un'immagine banner mostrata in cima al tuo profilo",
"manualApprovalLabel": "Approva manualmente i seguaci",
"manualApprovalHint": "Quando attivato, le richieste di seguimento richiedono la tua approvazione prima di avere effetto",
"actorTypeLabel": "Tipo di attore",
"actorTypeHint": "Come il tuo account appare nel fediverse. Person per individui, Service per bot o account automatizzati, Organization per gruppi o aziende.",
"linksLabel": "Link del profilo",
"linksHint": "Link mostrati sul tuo profilo fediverse. Aggiungi il tuo sito web, account social o altri URL. Le pagine che rimandano con rel=\"me\" saranno mostrate come verificate su Mastodon.",
"linkNameLabel": "Etichetta",
"linkValueLabel": "URL",
"addLink": "Aggiungi link",
"removeLink": "Rimuovi",
"authorizedFetchLabel": "Richiedi fetch autorizzato (modalità sicura)",
"authorizedFetchHint": "Quando attivato, solo i server con firme HTTP valide possono recuperare il tuo attore e le collezioni. Questo migliora la privacy ma potrebbe ridurre la compatibilità con alcuni client.",
"save": "Salva profilo",
"saved": "Profilo salvato. Le modifiche sono ora visibili nel fediverse.",
"public": {
"followPrompt": "Seguimi nel fediverse",
"copyHandle": "Copia identificativo",
"copied": "Copiato!",
"pinnedPosts": "Post in evidenza",
"recentPosts": "Post recenti",
"joinedDate": "Iscritto",
"posts": "Post",
"followers": "Seguaci",
"following": "Seguiti",
"viewOnSite": "Vedi sul sito"
},
"remote": {
"follow": "Segui",
"unfollow": "Smetti di seguire",
"viewOn": "Vedi su",
"postsTitle": "Post",
"noPosts": "Ancora nessun post da questo account.",
"followToSee": "Segui questo account per vedere i suoi post nella tua cronologia.",
"notFound": "Impossibile trovare questo account. Potrebbe essere stato eliminato o il server potrebbe non essere disponibile."
}
},
"migrate": {
"title": "Migrazione da Mastodon",
"intro": "Questa guida ti accompagna nel trasferimento della tua identità Mastodon al tuo sito IndieWeb. Completa ogni passaggio in ordine — i tuoi seguaci esistenti saranno notificati e potranno seguirti automaticamente.",
"step1Title": "Passaggio 1 — Collega il tuo vecchio account",
"step1Desc": "Comunica al fediverse che il tuo vecchio account Mastodon e questo sito appartengono alla stessa persona. Questo imposta la proprietà <code>alsoKnownAs</code> sul tuo attore ActivityPub, che Mastodon verifica prima di consentire un trasferimento.",
"aliasLabel": "URL del vecchio account Mastodon",
"aliasHint": "L'URL completo del tuo profilo Mastodon, es. https://mstdn.social/users/rmdes",
"aliasSave": "Salva alias",
"aliasCurrent": "Alias attuale",
"aliasNone": "Nessun alias configurato.",
"step2Title": "Passaggio 2 — Importa il tuo grafo sociale",
"step2Desc": "Carica i file CSV dall'esportazione dei dati Mastodon per trasferire le tue connessioni. Vai alla tua istanza Mastodon → Preferenze → Importa ed esporta → Esportazione dati per scaricarli.",
"importLegend": "Cosa importare",
"fileLabel": "File CSV",
"fileHint": "Seleziona un file CSV dalla tua esportazione dati Mastodon (es. following_accounts.csv o followers.csv)",
"importButton": "Importa",
"importFollowing": "Lista dei seguiti",
"importFollowingHint": "Account che segui — appariranno immediatamente nella tua lista Seguiti",
"importFollowers": "Lista dei seguaci",
"importFollowersHint": "I tuoi seguaci attuali — saranno registrati come in attesa finché non ti seguiranno di nuovo dopo il trasferimento al passaggio 3",
"step3Title": "Passaggio 3 — Trasferisci il tuo account",
"step3Desc": "Una volta salvato l'alias e importati i dati, vai alla tua istanza Mastodon → Preferenze → Account → <strong>Trasferisci a un altro account</strong>. Inserisci il tuo nuovo identificativo fediverse e conferma. Mastodon notificherà tutti i tuoi seguaci, e quelli i cui server lo supportano ti seguiranno automaticamente qui. Questo passaggio è irreversibile — il tuo vecchio account diventerà un reindirizzamento.",
"errorNoFile": "Seleziona un file CSV prima di importare.",
"success": "Importati %d seguiti, %d seguaci (%d falliti).",
"failedList": "Impossibile risolvere: %s",
"failedListSummary": "Identificativi falliti",
"aliasSuccess": "Alias salvato — il tuo documento attore ora include questo account come alsoKnownAs."
},
"refollow": {
"title": "Ri-seguimento in blocco",
"progress": "Progresso ri-seguimento",
"remaining": "Rimanenti",
"awaitingAccept": "In attesa di accettazione",
"accepted": "Accettato",
"failed": "Fallito",
"pause": "Pausa",
"resume": "Riprendi",
"status": {
"idle": "Inattivo",
"running": "In esecuzione",
"paused": "In pausa",
"completed": "Completato"
}
},
"moderation": {
"title": "Moderazione",
"blockedTitle": "Account bloccati",
"mutedActorsTitle": "Account silenziati",
"mutedKeywordsTitle": "Parole chiave silenziate",
"noBlocked": "Nessun account bloccato.",
"noMutedActors": "Nessun account silenziato.",
"noMutedKeywords": "Nessuna parola chiave silenziata.",
"unblock": "Sblocca",
"unmute": "Riattiva audio",
"addKeywordTitle": "Aggiungi parola chiave silenziata",
"keywordPlaceholder": "Inserisci parola chiave o frase…",
"addKeyword": "Aggiungi",
"muteActor": "Silenzia",
"blockActor": "Blocca",
"filterModeTitle": "Modalità filtro",
"filterModeHint": "Scegli come vengono gestiti i contenuti silenziati nella tua cronologia. Gli account bloccati sono sempre nascosti.",
"filterModeHide": "Nascondi — rimuovi dalla cronologia",
"filterModeWarn": "Avvisa — mostra dietro avviso di contenuto",
"cwMutedAccount": "Account silenziato",
"cwMutedKeyword": "Parola chiave silenziata:",
"cwFiltered": "Contenuto filtrato"
},
"compose": {
"title": "Scrivi risposta",
"placeholder": "Scrivi la tua risposta…",
"syndicateLabel": "Sindaca a",
"submitMicropub": "Pubblica risposta",
"cancel": "Annulla",
"errorEmpty": "Il contenuto della risposta non può essere vuoto",
"visibilityLabel": "Visibilità",
"visibilityPublic": "Pubblico",
"visibilityUnlisted": "Non elencato",
"visibilityFollowers": "Solo seguaci",
"cwLabel": "Avviso di contenuto",
"cwPlaceholder": "Scrivi il tuo avviso qui…"
},
"notifications": {
"title": "Notifiche",
"empty": "Ancora nessuna notifica. Le interazioni dagli altri utenti del fediverse appariranno qui.",
"liked": "ha messo mi piace al tuo post",
"boostedPost": "ha condiviso il tuo post",
"followedYou": "ti segue",
"repliedTo": "ha risposto al tuo post",
"mentionedYou": "ti ha menzionato",
"markAllRead": "Segna tutto come letto",
"clearAll": "Cancella tutto",
"clearConfirm": "Eliminare tutte le notifiche? Questa azione non può essere annullata.",
"dismiss": "Ignora",
"viewThread": "Vedi discussione",
"tabs": {
"all": "Tutte",
"replies": "Risposte",
"likes": "Mi piace",
"boosts": "Condivisioni",
"follows": "Seguiti",
"dms": "MD",
"reports": "Segnalazioni"
},
"emptyTab": "Ancora nessuna notifica %s."
},
"messages": {
"title": "Messaggi",
"empty": "Ancora nessun messaggio. I messaggi diretti dagli altri utenti del fediverse appariranno qui.",
"allConversations": "Tutte le conversazioni",
"compose": "Nuovo messaggio",
"send": "Invia messaggio",
"delete": "Elimina",
"markAllRead": "Segna tutto come letto",
"clearAll": "Cancella tutto",
"clearConfirm": "Eliminare tutti i messaggi? Questa azione non può essere annullata.",
"recipientLabel": "A",
"recipientPlaceholder": "@utente@istanza.social",
"placeholder": "Scrivi il tuo messaggio...",
"sentTo": "A",
"replyingTo": "In risposta a",
"sentYouDM": "ti ha inviato un messaggio diretto",
"viewMessage": "Vedi messaggio",
"errorEmpty": "Il contenuto del messaggio non può essere vuoto.",
"errorNoRecipient": "Inserisci un destinatario.",
"errorRecipientNotFound": "Impossibile trovare quell'utente. Prova con un identificativo completo @utente@dominio."
},
"reader": {
"title": "Lettore",
"tabs": {
"all": "Tutto",
"notes": "Note",
"articles": "Articoli",
"replies": "Risposte",
"boosts": "Condivisioni",
"media": "Media"
},
"empty": "La tua cronologia è vuota. Segui alcuni account per vedere i loro post qui.",
"boosted": "ha condiviso",
"replyingTo": "In risposta a",
"showContent": "Mostra contenuto",
"hideContent": "Nascondi contenuto",
"sensitiveContent": "Contenuto sensibile",
"videoNotSupported": "Il tuo browser non supporta l'elemento video.",
"audioNotSupported": "Il tuo browser non supporta l'elemento audio.",
"actions": {
"reply": "Rispondi",
"boost": "Condividi",
"unboost": "Annulla condivisione",
"like": "Mi piace",
"unlike": "Annulla mi piace",
"viewOriginal": "Vedi originale",
"liked": "Piaciuto",
"boosted": "Condiviso",
"likeError": "Impossibile mettere mi piace a questo post",
"boostError": "Impossibile condividere questo post"
},
"post": {
"title": "Dettaglio post",
"notFound": "Post non trovato o non più disponibile.",
"openExternal": "Apri sull'istanza originale",
"parentPosts": "Discussione",
"replies": "Risposte",
"back": "Torna alla cronologia",
"loadingThread": "Caricamento discussione...",
"threadError": "Impossibile caricare la discussione completa"
},
"resolve": {
"placeholder": "Incolla un URL del fediverse o un identificativo @utente@dominio…",
"label": "Cerca un post o account del fediverse",
"button": "Cerca",
"notFoundTitle": "Non trovato",
"notFound": "Impossibile trovare questo post o account. L'URL potrebbe essere non valido, il server non disponibile o il contenuto potrebbe essere stato eliminato.",
"followersLabel": "seguaci"
},
"linkPreview": {
"label": "Anteprima link"
},
"explore": {
"title": "Esplora",
"description": "Sfoglia le cronologie pubbliche di istanze remote compatibili con Mastodon.",
"instancePlaceholder": "Inserisci un hostname di istanza, es. mastodon.social",
"browse": "Sfoglia",
"local": "Locale",
"federated": "Federata",
"loadError": "Impossibile caricare la cronologia da questa istanza. Potrebbe non essere disponibile o non supportare l'API Mastodon.",
"timeout": "Richiesta scaduta. L'istanza potrebbe essere lenta o non disponibile.",
"noResults": "Nessun post trovato sulla cronologia pubblica di questa istanza.",
"invalidInstance": "Hostname istanza non valido. Inserisci un nome di dominio valido.",
"mauLabel": "MAU",
"timelineSupported": "Cronologia pubblica disponibile",
"timelineUnsupported": "Cronologia pubblica non disponibile",
"hashtagLabel": "Hashtag (opzionale)",
"hashtagPlaceholder": "es. indieweb",
"hashtagHint": "Filtra i risultati per un hashtag specifico",
"tabs": {
"label": "Schede esplora",
"search": "Cerca",
"pinAsTab": "Fissa come scheda",
"pinned": "Fissate",
"remove": "Rimuovi scheda",
"moveUp": "Sposta su",
"moveDown": "Sposta giù",
"addHashtag": "Aggiungi scheda hashtag",
"hashtagTabPlaceholder": "Inserisci hashtag",
"addTab": "Aggiungi",
"retry": "Riprova",
"noInstances": "Fissa prima alcune istanze per usare le schede hashtag.",
"sources": "Ricerca di #%s su %d istanza",
"sources_plural": "Ricerca di #%s su %d istanze",
"sourcesPartial": "%d di %d istanze hanno risposto"
}
},
"tagTimeline": {
"postsTagged": "%d post",
"postsTagged_plural": "%d post",
"noPosts": "Nessun post con #%s trovato nella tua cronologia.",
"followTag": "Segui hashtag",
"unfollowTag": "Smetti di seguire hashtag",
"following": "Seguiti"
},
"pagination": {
"newer": "← Più recenti",
"older": "Più vecchi →",
"loadMore": "Carica altro",
"loading": "Caricamento…",
"noMore": "Sei in pari."
}
},
"myProfile": {
"title": "Il mio profilo",
"posts": "post",
"editProfile": "Modifica profilo",
"empty": "Ancora niente qui.",
"tabs": {
"posts": "Post",
"replies": "Risposte",
"likes": "Mi piace",
"boosts": "Condivisioni"
}
},
"poll": {
"voters": "votanti",
"votes": "voti",
"closed": "Sondaggio chiuso",
"endsAt": "Termina"
},
"federation": {
"deleteSuccess": "Attività di eliminazione inviata ai seguaci",
"deleteButton": "Elimina dal fediverse"
},
"federationMgmt": {
"title": "Federazione",
"collections": "Stato delle collezioni",
"quickActions": "Azioni rapide",
"broadcastActor": "Trasmetti aggiornamento attore",
"debugDashboard": "Dashboard di debug",
"objectLookup": "Ricerca oggetto",
"lookupPlaceholder": "URL o identificativo @utente@dominio…",
"lookup": "Cerca",
"lookupLoading": "Risoluzione…",
"postActions": "Federazione post",
"viewJson": "JSON",
"rebroadcast": "Ritrasmetti attività Create",
"rebroadcastShort": "Reinvia",
"broadcastDelete": "Trasmetti attività Delete",
"deleteShort": "Elimina",
"noPosts": "Nessun post trovato.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Attività recente",
"viewAllActivities": "Vedi tutte le attività →"
},
"reports": {
"sentReport": "ha presentato una segnalazione",
"title": "Segnalazioni"
}
}
}

358
locales/nl.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Volgers",
"following": "Volgend",
"activities": "Activiteitenlog",
"featured": "Vastgezette berichten",
"featuredTags": "Uitgelichte tags",
"recentActivity": "Recente activiteit",
"noActivity": "Nog geen activiteit. Zodra je actor gefedereerd is, verschijnen interacties hier.",
"noFollowers": "Nog geen volgers.",
"noFollowing": "Volgt nog niemand.",
"pendingFollows": "In afwachting",
"noPendingFollows": "Geen volgverzoeken in afwachting.",
"approve": "Goedkeuren",
"reject": "Afwijzen",
"followApproved": "Volgverzoek goedgekeurd.",
"followRejected": "Volgverzoek afgewezen.",
"followRequest": "wil je volgen",
"followerCount": "%d volger",
"followerCount_plural": "%d volgers",
"followingCount": "%d volgend",
"followedAt": "Volgt sinds",
"source": "Bron",
"sourceImport": "Mastodon-import",
"sourceManual": "Handmatig",
"sourceFederation": "Federatie",
"sourceRefollowPending": "Opnieuw volgen in afwachting",
"sourceRefollowFailed": "Opnieuw volgen mislukt",
"direction": "Richting",
"directionInbound": "Ontvangen",
"directionOutbound": "Verzonden",
"profile": {
"title": "Profiel",
"intro": "Bewerk hoe je actor wordt weergegeven aan andere fediverse-gebruikers. Wijzigingen worden direct van kracht.",
"nameLabel": "Weergavenaam",
"nameHint": "Je naam zoals weergegeven op je fediverse-profiel",
"summaryLabel": "Biografie",
"summaryHint": "Een korte beschrijving van jezelf. HTML is toegestaan.",
"urlLabel": "Website-URL",
"urlHint": "Je websiteadres, weergegeven als link op je profiel",
"iconLabel": "Avatar-URL",
"iconHint": "URL van je profielfoto (vierkant, minimaal 400x400px aanbevolen)",
"imageLabel": "Headerafbeelding-URL",
"imageHint": "URL van een bannerafbeelding bovenaan je profiel",
"manualApprovalLabel": "Volgers handmatig goedkeuren",
"manualApprovalHint": "Wanneer ingeschakeld, vereisen volgverzoeken je goedkeuring voordat ze van kracht worden",
"actorTypeLabel": "Actortype",
"actorTypeHint": "Hoe je account in het fediverse verschijnt. Person voor individuen, Service voor bots of geautomatiseerde accounts, Organization voor groepen of bedrijven.",
"linksLabel": "Profiellinks",
"linksHint": "Links op je fediverse-profiel. Voeg je website, sociale accounts of andere URL's toe. Pagina's die teruglinken met rel=\"me\" worden als geverifieerd weergegeven op Mastodon.",
"linkNameLabel": "Label",
"linkValueLabel": "URL",
"addLink": "Link toevoegen",
"removeLink": "Verwijderen",
"authorizedFetchLabel": "Geautoriseerd ophalen vereisen (beveiligde modus)",
"authorizedFetchHint": "Wanneer ingeschakeld, kunnen alleen servers met geldige HTTP-handtekeningen je actor en collecties ophalen. Dit verbetert de privacy maar kan de compatibiliteit met sommige clients verminderen.",
"save": "Profiel opslaan",
"saved": "Profiel opgeslagen. Wijzigingen zijn nu zichtbaar in het fediverse.",
"public": {
"followPrompt": "Volg mij op het fediverse",
"copyHandle": "Handle kopiëren",
"copied": "Gekopieerd!",
"pinnedPosts": "Vastgezette berichten",
"recentPosts": "Recente berichten",
"joinedDate": "Lid sinds",
"posts": "Berichten",
"followers": "Volgers",
"following": "Volgend",
"viewOnSite": "Bekijk op site"
},
"remote": {
"follow": "Volgen",
"unfollow": "Ontvolgen",
"viewOn": "Bekijk op",
"postsTitle": "Berichten",
"noPosts": "Nog geen berichten van dit account.",
"followToSee": "Volg dit account om hun berichten in je tijdlijn te zien.",
"notFound": "Kon dit account niet vinden. Het is mogelijk verwijderd of de server is niet beschikbaar."
}
},
"migrate": {
"title": "Mastodon-migratie",
"intro": "Deze gids begeleidt je bij het verplaatsen van je Mastodon-identiteit naar je IndieWeb-site. Voltooi elke stap op volgorde — je bestaande volgers worden geïnformeerd en kunnen je automatisch opnieuw volgen.",
"step1Title": "Stap 1 — Koppel je oude account",
"step1Desc": "Laat het fediverse weten dat je oude Mastodon-account en deze site bij dezelfde persoon horen. Dit stelt de <code>alsoKnownAs</code>-eigenschap in op je ActivityPub-actor, die Mastodon controleert voordat een verplaatsing wordt toegestaan.",
"aliasLabel": "URL van oud Mastodon-account",
"aliasHint": "De volledige URL van je Mastodon-profiel, bijv. https://mstdn.social/users/rmdes",
"aliasSave": "Alias opslaan",
"aliasCurrent": "Huidige alias",
"aliasNone": "Nog geen alias geconfigureerd.",
"step2Title": "Stap 2 — Importeer je sociale netwerk",
"step2Desc": "Upload de CSV-bestanden van je Mastodon-data-export om je verbindingen over te nemen. Ga naar je Mastodon-instantie → Voorkeuren → Import en export → Data-export om ze te downloaden.",
"importLegend": "Wat importeren",
"fileLabel": "CSV-bestand",
"fileHint": "Selecteer een CSV-bestand van je Mastodon-data-export (bijv. following_accounts.csv of followers.csv)",
"importButton": "Importeren",
"importFollowing": "Volglijst",
"importFollowingHint": "Accounts die je volgt — ze verschijnen direct in je Volgend-lijst",
"importFollowers": "Volgerslijst",
"importFollowersHint": "Je huidige volgers — ze worden geregistreerd als in afwachting totdat ze je opnieuw volgen na de verplaatsing in stap 3",
"step3Title": "Stap 3 — Verplaats je account",
"step3Desc": "Nadat je je alias hebt opgeslagen en je gegevens hebt geïmporteerd, ga naar je Mastodon-instantie → Voorkeuren → Account → <strong>Verplaats naar een ander account</strong>. Voer je nieuwe fediverse-handle in en bevestig. Mastodon informeert al je volgers, en degenen van wie de servers het ondersteunen, zullen je hier automatisch opnieuw volgen. Deze stap is onomkeerbaar — je oude account wordt een doorverwijzing.",
"errorNoFile": "Selecteer een CSV-bestand voordat je importeert.",
"success": "%d volgend, %d volgers geïmporteerd (%d mislukt).",
"failedList": "Kon niet oplossen: %s",
"failedListSummary": "Mislukte handles",
"aliasSuccess": "Alias opgeslagen — je actordocument bevat nu dit account als alsoKnownAs."
},
"refollow": {
"title": "Batch opnieuw volgen",
"progress": "Voortgang opnieuw volgen",
"remaining": "Resterend",
"awaitingAccept": "Wacht op acceptatie",
"accepted": "Geaccepteerd",
"failed": "Mislukt",
"pause": "Pauzeren",
"resume": "Hervatten",
"status": {
"idle": "Inactief",
"running": "Actief",
"paused": "Gepauzeerd",
"completed": "Voltooid"
}
},
"moderation": {
"title": "Moderatie",
"blockedTitle": "Geblokkeerde accounts",
"mutedActorsTitle": "Gedempte accounts",
"mutedKeywordsTitle": "Gedempte trefwoorden",
"noBlocked": "Geen geblokkeerde accounts.",
"noMutedActors": "Geen gedempte accounts.",
"noMutedKeywords": "Geen gedempte trefwoorden.",
"unblock": "Deblokkeren",
"unmute": "Dempen opheffen",
"addKeywordTitle": "Gedempt trefwoord toevoegen",
"keywordPlaceholder": "Voer trefwoord of zin in…",
"addKeyword": "Toevoegen",
"muteActor": "Dempen",
"blockActor": "Blokkeren",
"filterModeTitle": "Filtermodus",
"filterModeHint": "Kies hoe gedempte inhoud in je tijdlijn wordt behandeld. Geblokkeerde accounts worden altijd verborgen.",
"filterModeHide": "Verbergen — verwijderen uit tijdlijn",
"filterModeWarn": "Waarschuwen — tonen achter inhoudswaarschuwing",
"cwMutedAccount": "Gedempt account",
"cwMutedKeyword": "Gedempt trefwoord:",
"cwFiltered": "Gefilterde inhoud"
},
"compose": {
"title": "Antwoord schrijven",
"placeholder": "Schrijf je antwoord…",
"syndicateLabel": "Syndiceren naar",
"submitMicropub": "Antwoord plaatsen",
"cancel": "Annuleren",
"errorEmpty": "Antwoordinhoud mag niet leeg zijn",
"visibilityLabel": "Zichtbaarheid",
"visibilityPublic": "Openbaar",
"visibilityUnlisted": "Niet vermeld",
"visibilityFollowers": "Alleen volgers",
"cwLabel": "Inhoudswaarschuwing",
"cwPlaceholder": "Schrijf je waarschuwing hier…"
},
"notifications": {
"title": "Meldingen",
"empty": "Nog geen meldingen. Interacties van andere fediverse-gebruikers verschijnen hier.",
"liked": "vond je bericht leuk",
"boostedPost": "heeft je bericht geboost",
"followedYou": "volgt je",
"repliedTo": "heeft op je bericht gereageerd",
"mentionedYou": "heeft je vermeld",
"markAllRead": "Alles als gelezen markeren",
"clearAll": "Alles wissen",
"clearConfirm": "Alle meldingen verwijderen? Dit kan niet ongedaan worden gemaakt.",
"dismiss": "Negeren",
"viewThread": "Draad bekijken",
"tabs": {
"all": "Alles",
"replies": "Antwoorden",
"likes": "Likes",
"boosts": "Boosts",
"follows": "Volgers",
"dms": "DM's",
"reports": "Rapporten"
},
"emptyTab": "Nog geen %s-meldingen."
},
"messages": {
"title": "Berichten",
"empty": "Nog geen berichten. Directe berichten van andere fediverse-gebruikers verschijnen hier.",
"allConversations": "Alle gesprekken",
"compose": "Nieuw bericht",
"send": "Bericht verzenden",
"delete": "Verwijderen",
"markAllRead": "Alles als gelezen markeren",
"clearAll": "Alles wissen",
"clearConfirm": "Alle berichten verwijderen? Dit kan niet ongedaan worden gemaakt.",
"recipientLabel": "Aan",
"recipientPlaceholder": "@gebruiker@instantie.social",
"placeholder": "Schrijf je bericht...",
"sentTo": "Aan",
"replyingTo": "Antwoord aan",
"sentYouDM": "heeft je een direct bericht gestuurd",
"viewMessage": "Bericht bekijken",
"errorEmpty": "Berichtinhoud mag niet leeg zijn.",
"errorNoRecipient": "Voer een ontvanger in.",
"errorRecipientNotFound": "Kon die gebruiker niet vinden. Probeer een volledig @gebruiker@domein-handle."
},
"reader": {
"title": "Lezer",
"tabs": {
"all": "Alles",
"notes": "Notities",
"articles": "Artikelen",
"replies": "Antwoorden",
"boosts": "Boosts",
"media": "Media"
},
"empty": "Je tijdlijn is leeg. Volg accounts om hun berichten hier te zien.",
"boosted": "heeft geboost",
"replyingTo": "Antwoord aan",
"showContent": "Inhoud tonen",
"hideContent": "Inhoud verbergen",
"sensitiveContent": "Gevoelige inhoud",
"videoNotSupported": "Je browser ondersteunt het video-element niet.",
"audioNotSupported": "Je browser ondersteunt het audio-element niet.",
"actions": {
"reply": "Antwoorden",
"boost": "Boosten",
"unboost": "Boost ongedaan maken",
"like": "Leuk vinden",
"unlike": "Leuk vinden ongedaan maken",
"viewOriginal": "Origineel bekijken",
"liked": "Leuk gevonden",
"boosted": "Geboost",
"likeError": "Kon dit bericht niet leuk vinden",
"boostError": "Kon dit bericht niet boosten"
},
"post": {
"title": "Berichtdetail",
"notFound": "Bericht niet gevonden of niet meer beschikbaar.",
"openExternal": "Openen op originele instantie",
"parentPosts": "Draad",
"replies": "Antwoorden",
"back": "Terug naar tijdlijn",
"loadingThread": "Draad laden...",
"threadError": "Kon volledige draad niet laden"
},
"resolve": {
"placeholder": "Plak een fediverse-URL of @gebruiker@domein-handle…",
"label": "Zoek een fediverse-bericht of -account op",
"button": "Opzoeken",
"notFoundTitle": "Niet gevonden",
"notFound": "Kon dit bericht of account niet vinden. De URL is mogelijk ongeldig, de server niet beschikbaar of de inhoud is verwijderd.",
"followersLabel": "volgers"
},
"linkPreview": {
"label": "Linkvoorbeeld"
},
"explore": {
"title": "Verkennen",
"description": "Blader door openbare tijdlijnen van externe Mastodon-compatibele instanties.",
"instancePlaceholder": "Voer een instantie-hostnaam in, bijv. mastodon.social",
"browse": "Bladeren",
"local": "Lokaal",
"federated": "Gefedereerd",
"loadError": "Kon tijdlijn van deze instantie niet laden. De instantie is mogelijk niet beschikbaar of ondersteunt de Mastodon-API niet.",
"timeout": "Verzoek verlopen. De instantie is mogelijk traag of niet beschikbaar.",
"noResults": "Geen berichten gevonden op de openbare tijdlijn van deze instantie.",
"invalidInstance": "Ongeldige instantie-hostnaam. Voer een geldige domeinnaam in.",
"mauLabel": "MAU",
"timelineSupported": "Openbare tijdlijn beschikbaar",
"timelineUnsupported": "Openbare tijdlijn niet beschikbaar",
"hashtagLabel": "Hashtag (optioneel)",
"hashtagPlaceholder": "bijv. indieweb",
"hashtagHint": "Resultaten filteren op een specifieke hashtag",
"tabs": {
"label": "Verken-tabbladen",
"search": "Zoeken",
"pinAsTab": "Vastzetten als tabblad",
"pinned": "Vastgezet",
"remove": "Tabblad verwijderen",
"moveUp": "Omhoog",
"moveDown": "Omlaag",
"addHashtag": "Hashtag-tabblad toevoegen",
"hashtagTabPlaceholder": "Voer hashtag in",
"addTab": "Toevoegen",
"retry": "Opnieuw proberen",
"noInstances": "Zet eerst instanties vast om hashtag-tabbladen te gebruiken.",
"sources": "Zoeken naar #%s op %d instantie",
"sources_plural": "Zoeken naar #%s op %d instanties",
"sourcesPartial": "%d van %d instanties hebben gereageerd"
}
},
"tagTimeline": {
"postsTagged": "%d bericht",
"postsTagged_plural": "%d berichten",
"noPosts": "Geen berichten met #%s gevonden in je tijdlijn.",
"followTag": "Hashtag volgen",
"unfollowTag": "Hashtag ontvolgen",
"following": "Volgend"
},
"pagination": {
"newer": "← Nieuwer",
"older": "Ouder →",
"loadMore": "Meer laden",
"loading": "Laden…",
"noMore": "Je bent helemaal bij."
}
},
"myProfile": {
"title": "Mijn profiel",
"posts": "berichten",
"editProfile": "Profiel bewerken",
"empty": "Hier is nog niets.",
"tabs": {
"posts": "Berichten",
"replies": "Antwoorden",
"likes": "Likes",
"boosts": "Boosts"
}
},
"poll": {
"voters": "stemmers",
"votes": "stemmen",
"closed": "Peiling gesloten",
"endsAt": "Eindigt"
},
"federation": {
"deleteSuccess": "Verwijderactiviteit verzonden naar volgers",
"deleteButton": "Verwijderen uit het fediverse"
},
"federationMgmt": {
"title": "Federatie",
"collections": "Collectiestatus",
"quickActions": "Snelle acties",
"broadcastActor": "Actor-update uitzenden",
"debugDashboard": "Debug-dashboard",
"objectLookup": "Object opzoeken",
"lookupPlaceholder": "URL of @gebruiker@domein-handle…",
"lookup": "Opzoeken",
"lookupLoading": "Oplossen…",
"postActions": "Berichtfederatie",
"viewJson": "JSON",
"rebroadcast": "Create-activiteit opnieuw uitzenden",
"rebroadcastShort": "Opnieuw verzenden",
"broadcastDelete": "Delete-activiteit uitzenden",
"deleteShort": "Verwijderen",
"noPosts": "Geen berichten gevonden.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Recente activiteit",
"viewAllActivities": "Alle activiteiten bekijken →"
},
"reports": {
"sentReport": "heeft een rapport ingediend",
"title": "Rapporten"
}
}
}

358
locales/pl.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Obserwujący",
"following": "Obserwowani",
"activities": "Dziennik aktywności",
"featured": "Przypięte wpisy",
"featuredTags": "Wyróżnione tagi",
"recentActivity": "Ostatnia aktywność",
"noActivity": "Brak aktywności. Gdy twój aktor zostanie sfederowany, interakcje pojawią się tutaj.",
"noFollowers": "Brak obserwujących.",
"noFollowing": "Nie obserwujesz jeszcze nikogo.",
"pendingFollows": "Oczekujące",
"noPendingFollows": "Brak oczekujących próśb o obserwowanie.",
"approve": "Zatwierdź",
"reject": "Odrzuć",
"followApproved": "Prośba o obserwowanie zatwierdzona.",
"followRejected": "Prośba o obserwowanie odrzucona.",
"followRequest": "chce cię obserwować",
"followerCount": "%d obserwujący",
"followerCount_plural": "%d obserwujących",
"followingCount": "%d obserwowanych",
"followedAt": "Obserwuje od",
"source": "Źródło",
"sourceImport": "Import z Mastodon",
"sourceManual": "Ręczne",
"sourceFederation": "Federacja",
"sourceRefollowPending": "Ponowne obserwowanie oczekuje",
"sourceRefollowFailed": "Ponowne obserwowanie nie powiodło się",
"direction": "Kierunek",
"directionInbound": "Odebrane",
"directionOutbound": "Wysłane",
"profile": {
"title": "Profil",
"intro": "Edytuj sposób, w jaki twój aktor jest widoczny dla innych użytkowników fediverse. Zmiany obowiązują natychmiast.",
"nameLabel": "Nazwa wyświetlana",
"nameHint": "Twoje imię wyświetlane na twoim profilu fediverse",
"summaryLabel": "Biogram",
"summaryHint": "Krótki opis o sobie. HTML jest dozwolony.",
"urlLabel": "Adres URL strony",
"urlHint": "Adres twojej strony internetowej, wyświetlany jako link na twoim profilu",
"iconLabel": "Adres URL awatara",
"iconHint": "Adres URL twojego zdjęcia profilowego (kwadratowe, zalecane min. 400x400px)",
"imageLabel": "Adres URL obrazu nagłówka",
"imageHint": "Adres URL obrazu bannerowego wyświetlanego na górze twojego profilu",
"manualApprovalLabel": "Ręcznie zatwierdzaj obserwujących",
"manualApprovalHint": "Po włączeniu prośby o obserwowanie wymagają twojej zgody zanim zaczną obowiązywać",
"actorTypeLabel": "Typ aktora",
"actorTypeHint": "Jak twoje konto wygląda w fediverse. Person dla osób indywidualnych, Service dla botów lub zautomatyzowanych kont, Organization dla grup lub firm.",
"linksLabel": "Linki profilu",
"linksHint": "Linki wyświetlane na twoim profilu fediverse. Dodaj swoją stronę, konta społecznościowe lub inne adresy URL. Strony, które linkują zwrotnie z rel=\"me\", będą wyświetlane jako zweryfikowane na Mastodon.",
"linkNameLabel": "Etykieta",
"linkValueLabel": "URL",
"addLink": "Dodaj link",
"removeLink": "Usuń",
"authorizedFetchLabel": "Wymagaj autoryzowanego pobierania (tryb bezpieczny)",
"authorizedFetchHint": "Po włączeniu tylko serwery z prawidłowymi podpisami HTTP mogą pobierać twojego aktora i kolekcje. Poprawia to prywatność, ale może zmniejszyć kompatybilność z niektórymi klientami.",
"save": "Zapisz profil",
"saved": "Profil zapisany. Zmiany są teraz widoczne w fediverse.",
"public": {
"followPrompt": "Obserwuj mnie w fediverse",
"copyHandle": "Kopiuj identyfikator",
"copied": "Skopiowano!",
"pinnedPosts": "Przypięte wpisy",
"recentPosts": "Ostatnie wpisy",
"joinedDate": "Dołączył(a)",
"posts": "Wpisy",
"followers": "Obserwujący",
"following": "Obserwowani",
"viewOnSite": "Zobacz na stronie"
},
"remote": {
"follow": "Obserwuj",
"unfollow": "Przestań obserwować",
"viewOn": "Zobacz na",
"postsTitle": "Wpisy",
"noPosts": "Brak wpisów z tego konta.",
"followToSee": "Obserwuj to konto, aby zobaczyć jego wpisy na swojej osi czasu.",
"notFound": "Nie można znaleźć tego konta. Mogło zostać usunięte lub serwer może być niedostępny."
}
},
"migrate": {
"title": "Migracja z Mastodon",
"intro": "Ten przewodnik przeprowadzi cię przez przeniesienie tożsamości Mastodon na twoją stronę IndieWeb. Wykonaj każdy krok po kolei — twoi obecni obserwujący zostaną powiadomieni i będą mogli automatycznie ponownie cię obserwować.",
"step1Title": "Krok 1 — Połącz stare konto",
"step1Desc": "Poinformuj fediverse, że twoje stare konto Mastodon i ta strona należą do tej samej osoby. Ustawia to właściwość <code>alsoKnownAs</code> na twoim aktorze ActivityPub, którą Mastodon sprawdza przed zezwoleniem na przeniesienie.",
"aliasLabel": "Adres URL starego konta Mastodon",
"aliasHint": "Pełny adres URL twojego profilu Mastodon, np. https://mstdn.social/users/rmdes",
"aliasSave": "Zapisz alias",
"aliasCurrent": "Obecny alias",
"aliasNone": "Alias jeszcze nie skonfigurowany.",
"step2Title": "Krok 2 — Importuj swój graf społeczny",
"step2Desc": "Prześlij pliki CSV z eksportu danych Mastodon, aby przenieść swoje połączenia. Przejdź do swojej instancji Mastodon → Preferencje → Import i eksport → Eksport danych, aby je pobrać.",
"importLegend": "Co importować",
"fileLabel": "Plik CSV",
"fileHint": "Wybierz plik CSV z eksportu danych Mastodon (np. following_accounts.csv lub followers.csv)",
"importButton": "Importuj",
"importFollowing": "Lista obserwowanych",
"importFollowingHint": "Konta, które obserwujesz — pojawią się natychmiast na twojej liście Obserwowanych",
"importFollowers": "Lista obserwujących",
"importFollowersHint": "Twoi obecni obserwujący — zostaną zarejestrowani jako oczekujący, dopóki nie zaczną cię ponownie obserwować po przeniesieniu w kroku 3",
"step3Title": "Krok 3 — Przenieś swoje konto",
"step3Desc": "Po zapisaniu aliasu i zaimportowaniu danych przejdź do swojej instancji Mastodon → Preferencje → Konto → <strong>Przenieś na inne konto</strong>. Wprowadź swój nowy identyfikator fediverse i potwierdź. Mastodon powiadomi wszystkich twoich obserwujących, a ci, których serwery to obsługują, automatycznie zaczną cię obserwować tutaj. Ten krok jest nieodwracalny — twoje stare konto stanie się przekierowaniem.",
"errorNoFile": "Wybierz plik CSV przed importowaniem.",
"success": "Zaimportowano %d obserwowanych, %d obserwujących (%d niepowodzenia).",
"failedList": "Nie można rozwiązać: %s",
"failedListSummary": "Nieudane identyfikatory",
"aliasSuccess": "Alias zapisany — twój dokument aktora zawiera teraz to konto jako alsoKnownAs."
},
"refollow": {
"title": "Masowe ponowne obserwowanie",
"progress": "Postęp ponownego obserwowania",
"remaining": "Pozostało",
"awaitingAccept": "Oczekuje na akceptację",
"accepted": "Zaakceptowano",
"failed": "Niepowodzenie",
"pause": "Wstrzymaj",
"resume": "Wznów",
"status": {
"idle": "Bezczynny",
"running": "W trakcie",
"paused": "Wstrzymany",
"completed": "Ukończony"
}
},
"moderation": {
"title": "Moderacja",
"blockedTitle": "Zablokowane konta",
"mutedActorsTitle": "Wyciszone konta",
"mutedKeywordsTitle": "Wyciszone słowa kluczowe",
"noBlocked": "Brak zablokowanych kont.",
"noMutedActors": "Brak wyciszonych kont.",
"noMutedKeywords": "Brak wyciszonych słów kluczowych.",
"unblock": "Odblokuj",
"unmute": "Wyłącz wyciszenie",
"addKeywordTitle": "Dodaj wyciszone słowo kluczowe",
"keywordPlaceholder": "Wprowadź słowo kluczowe lub frazę…",
"addKeyword": "Dodaj",
"muteActor": "Wycisz",
"blockActor": "Zablokuj",
"filterModeTitle": "Tryb filtrowania",
"filterModeHint": "Wybierz, jak wyciszona treść jest obsługiwana na twojej osi czasu. Zablokowane konta są zawsze ukrywane.",
"filterModeHide": "Ukryj — usuń z osi czasu",
"filterModeWarn": "Ostrzeż — pokaż za ostrzeżeniem o treści",
"cwMutedAccount": "Wyciszone konto",
"cwMutedKeyword": "Wyciszone słowo kluczowe:",
"cwFiltered": "Filtrowana treść"
},
"compose": {
"title": "Napisz odpowiedź",
"placeholder": "Napisz swoją odpowiedź…",
"syndicateLabel": "Syndykuj do",
"submitMicropub": "Opublikuj odpowiedź",
"cancel": "Anuluj",
"errorEmpty": "Treść odpowiedzi nie może być pusta",
"visibilityLabel": "Widoczność",
"visibilityPublic": "Publiczny",
"visibilityUnlisted": "Niewidoczny na liście",
"visibilityFollowers": "Tylko obserwujący",
"cwLabel": "Ostrzeżenie o treści",
"cwPlaceholder": "Napisz swoje ostrzeżenie tutaj…"
},
"notifications": {
"title": "Powiadomienia",
"empty": "Brak powiadomień. Interakcje od innych użytkowników fediverse pojawią się tutaj.",
"liked": "polubił(a) twój wpis",
"boostedPost": "podbił(a) twój wpis",
"followedYou": "zaczął/zaczęła cię obserwować",
"repliedTo": "odpowiedział(a) na twój wpis",
"mentionedYou": "wspomniał(a) o tobie",
"markAllRead": "Oznacz wszystko jako przeczytane",
"clearAll": "Wyczyść wszystko",
"clearConfirm": "Usunąć wszystkie powiadomienia? Tej operacji nie można cofnąć.",
"dismiss": "Odrzuć",
"viewThread": "Zobacz wątek",
"tabs": {
"all": "Wszystkie",
"replies": "Odpowiedzi",
"likes": "Polubienia",
"boosts": "Podbicia",
"follows": "Obserwowania",
"dms": "WP",
"reports": "Zgłoszenia"
},
"emptyTab": "Brak powiadomień %s."
},
"messages": {
"title": "Wiadomości",
"empty": "Brak wiadomości. Wiadomości bezpośrednie od innych użytkowników fediverse pojawią się tutaj.",
"allConversations": "Wszystkie rozmowy",
"compose": "Nowa wiadomość",
"send": "Wyślij wiadomość",
"delete": "Usuń",
"markAllRead": "Oznacz wszystko jako przeczytane",
"clearAll": "Wyczyść wszystko",
"clearConfirm": "Usunąć wszystkie wiadomości? Tej operacji nie można cofnąć.",
"recipientLabel": "Do",
"recipientPlaceholder": "@użytkownik@instancja.social",
"placeholder": "Napisz swoją wiadomość...",
"sentTo": "Do",
"replyingTo": "Odpowiedź do",
"sentYouDM": "wysłał(a) ci wiadomość bezpośrednią",
"viewMessage": "Zobacz wiadomość",
"errorEmpty": "Treść wiadomości nie może być pusta.",
"errorNoRecipient": "Wprowadź odbiorcę.",
"errorRecipientNotFound": "Nie można znaleźć tego użytkownika. Spróbuj pełnego identyfikatora @użytkownik@domena."
},
"reader": {
"title": "Czytnik",
"tabs": {
"all": "Wszystko",
"notes": "Notatki",
"articles": "Artykuły",
"replies": "Odpowiedzi",
"boosts": "Podbicia",
"media": "Media"
},
"empty": "Twoja oś czasu jest pusta. Obserwuj konta, aby zobaczyć ich wpisy tutaj.",
"boosted": "podbił(a)",
"replyingTo": "Odpowiedź do",
"showContent": "Pokaż treść",
"hideContent": "Ukryj treść",
"sensitiveContent": "Wrażliwa treść",
"videoNotSupported": "Twoja przeglądarka nie obsługuje elementu wideo.",
"audioNotSupported": "Twoja przeglądarka nie obsługuje elementu audio.",
"actions": {
"reply": "Odpowiedz",
"boost": "Podbij",
"unboost": "Cofnij podbicie",
"like": "Polub",
"unlike": "Cofnij polubienie",
"viewOriginal": "Zobacz oryginał",
"liked": "Polubiono",
"boosted": "Podbito",
"likeError": "Nie można polubić tego wpisu",
"boostError": "Nie można podbić tego wpisu"
},
"post": {
"title": "Szczegóły wpisu",
"notFound": "Wpis nie został znaleziony lub nie jest już dostępny.",
"openExternal": "Otwórz na oryginalnej instancji",
"parentPosts": "Wątek",
"replies": "Odpowiedzi",
"back": "Wróć do osi czasu",
"loadingThread": "Ładowanie wątku...",
"threadError": "Nie można załadować pełnego wątku"
},
"resolve": {
"placeholder": "Wklej adres URL fediverse lub identyfikator @użytkownik@domena…",
"label": "Wyszukaj wpis lub konto fediverse",
"button": "Wyszukaj",
"notFoundTitle": "Nie znaleziono",
"notFound": "Nie można znaleźć tego wpisu lub konta. Adres URL może być nieprawidłowy, serwer może być niedostępny lub treść mogła zostać usunięta.",
"followersLabel": "obserwujących"
},
"linkPreview": {
"label": "Podgląd linku"
},
"explore": {
"title": "Eksploruj",
"description": "Przeglądaj publiczne osie czasu ze zdalnych instancji kompatybilnych z Mastodon.",
"instancePlaceholder": "Wprowadź nazwę hosta instancji, np. mastodon.social",
"browse": "Przeglądaj",
"local": "Lokalna",
"federated": "Sfederowana",
"loadError": "Nie można załadować osi czasu z tej instancji. Może być niedostępna lub nie obsługiwać API Mastodon.",
"timeout": "Przekroczono limit czasu żądania. Instancja może być wolna lub niedostępna.",
"noResults": "Nie znaleziono wpisów na publicznej osi czasu tej instancji.",
"invalidInstance": "Nieprawidłowa nazwa hosta instancji. Wprowadź prawidłową nazwę domeny.",
"mauLabel": "MAU",
"timelineSupported": "Publiczna oś czasu dostępna",
"timelineUnsupported": "Publiczna oś czasu niedostępna",
"hashtagLabel": "Hashtag (opcjonalnie)",
"hashtagPlaceholder": "np. indieweb",
"hashtagHint": "Filtruj wyniki według określonego hashtaga",
"tabs": {
"label": "Karty eksploracji",
"search": "Szukaj",
"pinAsTab": "Przypnij jako kartę",
"pinned": "Przypięte",
"remove": "Usuń kartę",
"moveUp": "Przesuń w górę",
"moveDown": "Przesuń w dół",
"addHashtag": "Dodaj kartę hashtaga",
"hashtagTabPlaceholder": "Wprowadź hashtag",
"addTab": "Dodaj",
"retry": "Ponów",
"noInstances": "Najpierw przypnij instancje, aby używać kart hashtagów.",
"sources": "Wyszukiwanie #%s na %d instancji",
"sources_plural": "Wyszukiwanie #%s na %d instancjach",
"sourcesPartial": "%d z %d instancji odpowiedziało"
}
},
"tagTimeline": {
"postsTagged": "%d wpis",
"postsTagged_plural": "%d wpisów",
"noPosts": "Nie znaleziono wpisów z #%s na twojej osi czasu.",
"followTag": "Obserwuj hashtag",
"unfollowTag": "Przestań obserwować hashtag",
"following": "Obserwujesz"
},
"pagination": {
"newer": "← Nowsze",
"older": "Starsze →",
"loadMore": "Załaduj więcej",
"loading": "Ładowanie…",
"noMore": "Jesteś na bieżąco."
}
},
"myProfile": {
"title": "Mój profil",
"posts": "wpisy",
"editProfile": "Edytuj profil",
"empty": "Tutaj jeszcze nic nie ma.",
"tabs": {
"posts": "Wpisy",
"replies": "Odpowiedzi",
"likes": "Polubienia",
"boosts": "Podbicia"
}
},
"poll": {
"voters": "głosujących",
"votes": "głosów",
"closed": "Ankieta zamknięta",
"endsAt": "Kończy się"
},
"federation": {
"deleteSuccess": "Aktywność usunięcia wysłana do obserwujących",
"deleteButton": "Usuń z fediverse"
},
"federationMgmt": {
"title": "Federacja",
"collections": "Stan kolekcji",
"quickActions": "Szybkie akcje",
"broadcastActor": "Rozgłoś aktualizację aktora",
"debugDashboard": "Panel debugowania",
"objectLookup": "Wyszukiwanie obiektu",
"lookupPlaceholder": "URL lub identyfikator @użytkownik@domena…",
"lookup": "Wyszukaj",
"lookupLoading": "Rozwiązywanie…",
"postActions": "Federacja wpisów",
"viewJson": "JSON",
"rebroadcast": "Ponownie rozgłoś aktywność Create",
"rebroadcastShort": "Wyślij ponownie",
"broadcastDelete": "Rozgłoś aktywność Delete",
"deleteShort": "Usuń",
"noPosts": "Nie znaleziono wpisów.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Ostatnia aktywność",
"viewAllActivities": "Zobacz wszystkie aktywności →"
},
"reports": {
"sentReport": "złożył(a) zgłoszenie",
"title": "Zgłoszenia"
}
}
}

358
locales/pt-BR.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Seguidores",
"following": "Seguindo",
"activities": "Log de atividade",
"featured": "Posts fixados",
"featuredTags": "Tags em destaque",
"recentActivity": "Atividade recente",
"noActivity": "Ainda sem atividade. Quando o seu ator estiver federado, as interações vão aparecer aqui.",
"noFollowers": "Ainda sem seguidores.",
"noFollowing": "Ainda não segue ninguém.",
"pendingFollows": "Pendentes",
"noPendingFollows": "Sem solicitações de seguimento pendentes.",
"approve": "Aprovar",
"reject": "Rejeitar",
"followApproved": "Solicitação de seguimento aprovada.",
"followRejected": "Solicitação de seguimento rejeitada.",
"followRequest": "solicitou seguir você",
"followerCount": "%d seguidor",
"followerCount_plural": "%d seguidores",
"followingCount": "%d seguindo",
"followedAt": "Seguindo desde",
"source": "Fonte",
"sourceImport": "Importação do Mastodon",
"sourceManual": "Manual",
"sourceFederation": "Federação",
"sourceRefollowPending": "Re-seguimento pendente",
"sourceRefollowFailed": "Re-seguimento falhou",
"direction": "Direção",
"directionInbound": "Recebido",
"directionOutbound": "Enviado",
"profile": {
"title": "Perfil",
"intro": "Edite como o seu ator aparece para outros usuários do fediverse. As mudanças entram em vigor imediatamente.",
"nameLabel": "Nome de exibição",
"nameHint": "Seu nome como mostrado no seu perfil do fediverse",
"summaryLabel": "Bio",
"summaryHint": "Uma breve descrição sobre você. HTML é permitido.",
"urlLabel": "URL do site",
"urlHint": "O endereço do seu site, mostrado como link no seu perfil",
"iconLabel": "URL do avatar",
"iconHint": "URL da sua foto de perfil (quadrada, recomendado pelo menos 400x400px)",
"imageLabel": "URL da imagem de cabeçalho",
"imageHint": "URL de uma imagem de banner exibida no topo do seu perfil",
"manualApprovalLabel": "Aprovar seguidores manualmente",
"manualApprovalHint": "Quando ativado, solicitações de seguimento precisam da sua aprovação antes de terem efeito",
"actorTypeLabel": "Tipo de ator",
"actorTypeHint": "Como sua conta aparece no fediverse. Person para indivíduos, Service para bots ou contas automatizadas, Organization para grupos ou empresas.",
"linksLabel": "Links do perfil",
"linksHint": "Links exibidos no seu perfil do fediverse. Adicione seu site, contas sociais ou outros URLs. Páginas que apontem de volta com rel=\"me\" aparecerão como verificadas no Mastodon.",
"linkNameLabel": "Rótulo",
"linkValueLabel": "URL",
"addLink": "Adicionar link",
"removeLink": "Remover",
"authorizedFetchLabel": "Exigir busca autorizada (modo seguro)",
"authorizedFetchHint": "Quando ativado, apenas servidores com assinaturas HTTP válidas podem buscar seu ator e coleções. Isso melhora a privacidade mas pode reduzir a compatibilidade com alguns clientes.",
"save": "Salvar perfil",
"saved": "Perfil salvo. As mudanças agora estão visíveis no fediverse.",
"public": {
"followPrompt": "Siga-me no fediverse",
"copyHandle": "Copiar identificador",
"copied": "Copiado!",
"pinnedPosts": "Posts fixados",
"recentPosts": "Posts recentes",
"joinedDate": "Entrou em",
"posts": "Posts",
"followers": "Seguidores",
"following": "Seguindo",
"viewOnSite": "Ver no site"
},
"remote": {
"follow": "Seguir",
"unfollow": "Deixar de seguir",
"viewOn": "Ver em",
"postsTitle": "Posts",
"noPosts": "Ainda sem posts desta conta.",
"followToSee": "Siga esta conta para ver seus posts na sua linha do tempo.",
"notFound": "Não foi possível encontrar esta conta. Ela pode ter sido excluída ou o servidor pode estar indisponível."
}
},
"migrate": {
"title": "Migração do Mastodon",
"intro": "Este guia acompanha você na transferência da sua identidade do Mastodon para o seu site IndieWeb. Complete cada passo na ordem — seus seguidores existentes serão notificados e poderão seguir você automaticamente.",
"step1Title": "Passo 1 — Vincular sua conta antiga",
"step1Desc": "Informe ao fediverse que sua conta antiga do Mastodon e este site pertencem à mesma pessoa. Isso define a propriedade <code>alsoKnownAs</code> no seu ator ActivityPub, que o Mastodon verifica antes de permitir uma transferência.",
"aliasLabel": "URL da conta antiga do Mastodon",
"aliasHint": "A URL completa do seu perfil do Mastodon, ex. https://mstdn.social/users/rmdes",
"aliasSave": "Salvar alias",
"aliasCurrent": "Alias atual",
"aliasNone": "Nenhum alias configurado ainda.",
"step2Title": "Passo 2 — Importar seu grafo social",
"step2Desc": "Faça upload dos arquivos CSV da sua exportação de dados do Mastodon para trazer suas conexões. Vá para sua instância do Mastodon → Preferências → Importar e exportar → Exportação de dados para baixá-los.",
"importLegend": "O que importar",
"fileLabel": "Arquivo CSV",
"fileHint": "Selecione um arquivo CSV da sua exportação de dados do Mastodon (ex. following_accounts.csv ou followers.csv)",
"importButton": "Importar",
"importFollowing": "Lista de seguidos",
"importFollowingHint": "Contas que você segue — elas aparecerão imediatamente na sua lista de Seguindo",
"importFollowers": "Lista de seguidores",
"importFollowersHint": "Seus seguidores atuais — serão registrados como pendentes até seguirem você novamente após a transferência no passo 3",
"step3Title": "Passo 3 — Transferir sua conta",
"step3Desc": "Depois de salvar seu alias e importar seus dados, vá para sua instância do Mastodon → Preferências → Conta → <strong>Transferir para outra conta</strong>. Digite seu novo identificador do fediverse e confirme. O Mastodon notificará todos os seus seguidores, e aqueles cujos servidores suportarem vão seguir você automaticamente aqui. Este passo é irreversível — sua conta antiga vai se tornar um redirecionamento.",
"errorNoFile": "Por favor, selecione um arquivo CSV antes de importar.",
"success": "Importados %d seguidos, %d seguidores (%d falharam).",
"failedList": "Não foi possível resolver: %s",
"failedListSummary": "Identificadores com falha",
"aliasSuccess": "Alias salvo — seu documento de ator agora inclui esta conta como alsoKnownAs."
},
"refollow": {
"title": "Re-seguimento em lote",
"progress": "Progresso do re-seguimento",
"remaining": "Restantes",
"awaitingAccept": "Aguardando aceitação",
"accepted": "Aceito",
"failed": "Falhou",
"pause": "Pausar",
"resume": "Retomar",
"status": {
"idle": "Inativo",
"running": "Em execução",
"paused": "Pausado",
"completed": "Concluído"
}
},
"moderation": {
"title": "Moderação",
"blockedTitle": "Contas bloqueadas",
"mutedActorsTitle": "Contas silenciadas",
"mutedKeywordsTitle": "Palavras-chave silenciadas",
"noBlocked": "Sem contas bloqueadas.",
"noMutedActors": "Sem contas silenciadas.",
"noMutedKeywords": "Sem palavras-chave silenciadas.",
"unblock": "Desbloquear",
"unmute": "Remover silêncio",
"addKeywordTitle": "Adicionar palavra-chave silenciada",
"keywordPlaceholder": "Digite palavra-chave ou frase…",
"addKeyword": "Adicionar",
"muteActor": "Silenciar",
"blockActor": "Bloquear",
"filterModeTitle": "Modo de filtro",
"filterModeHint": "Escolha como o conteúdo silenciado é tratado na sua linha do tempo. Contas bloqueadas ficam sempre ocultas.",
"filterModeHide": "Ocultar — remover da linha do tempo",
"filterModeWarn": "Avisar — mostrar atrás de aviso de conteúdo",
"cwMutedAccount": "Conta silenciada",
"cwMutedKeyword": "Palavra-chave silenciada:",
"cwFiltered": "Conteúdo filtrado"
},
"compose": {
"title": "Escrever resposta",
"placeholder": "Escreva sua resposta…",
"syndicateLabel": "Sindicar para",
"submitMicropub": "Publicar resposta",
"cancel": "Cancelar",
"errorEmpty": "O conteúdo da resposta não pode estar vazio",
"visibilityLabel": "Visibilidade",
"visibilityPublic": "Público",
"visibilityUnlisted": "Não listado",
"visibilityFollowers": "Apenas seguidores",
"cwLabel": "Aviso de conteúdo",
"cwPlaceholder": "Escreva seu aviso aqui…"
},
"notifications": {
"title": "Notificações",
"empty": "Ainda sem notificações. Interações de outros usuários do fediverse vão aparecer aqui.",
"liked": "curtiu seu post",
"boostedPost": "compartilhou seu post",
"followedYou": "seguiu você",
"repliedTo": "respondeu ao seu post",
"mentionedYou": "mencionou você",
"markAllRead": "Marcar tudo como lido",
"clearAll": "Limpar tudo",
"clearConfirm": "Excluir todas as notificações? Isso não pode ser desfeito.",
"dismiss": "Dispensar",
"viewThread": "Ver thread",
"tabs": {
"all": "Todas",
"replies": "Respostas",
"likes": "Curtidas",
"boosts": "Compartilhamentos",
"follows": "Seguimentos",
"dms": "MDs",
"reports": "Denúncias"
},
"emptyTab": "Ainda sem notificações de %s."
},
"messages": {
"title": "Mensagens",
"empty": "Ainda sem mensagens. Mensagens diretas de outros usuários do fediverse vão aparecer aqui.",
"allConversations": "Todas as conversas",
"compose": "Nova mensagem",
"send": "Enviar mensagem",
"delete": "Excluir",
"markAllRead": "Marcar tudo como lido",
"clearAll": "Limpar tudo",
"clearConfirm": "Excluir todas as mensagens? Isso não pode ser desfeito.",
"recipientLabel": "Para",
"recipientPlaceholder": "@usuario@instancia.social",
"placeholder": "Escreva sua mensagem...",
"sentTo": "Para",
"replyingTo": "Respondendo a",
"sentYouDM": "enviou uma mensagem direta para você",
"viewMessage": "Ver mensagem",
"errorEmpty": "O conteúdo da mensagem não pode estar vazio.",
"errorNoRecipient": "Por favor, digite um destinatário.",
"errorRecipientNotFound": "Não foi possível encontrar esse usuário. Tente um identificador completo @usuario@dominio."
},
"reader": {
"title": "Leitor",
"tabs": {
"all": "Tudo",
"notes": "Notas",
"articles": "Artigos",
"replies": "Respostas",
"boosts": "Compartilhamentos",
"media": "Mídia"
},
"empty": "Sua linha do tempo está vazia. Siga algumas contas para ver seus posts aqui.",
"boosted": "compartilhou",
"replyingTo": "Respondendo a",
"showContent": "Mostrar conteúdo",
"hideContent": "Ocultar conteúdo",
"sensitiveContent": "Conteúdo sensível",
"videoNotSupported": "Seu navegador não suporta o elemento de vídeo.",
"audioNotSupported": "Seu navegador não suporta o elemento de áudio.",
"actions": {
"reply": "Responder",
"boost": "Compartilhar",
"unboost": "Desfazer compartilhamento",
"like": "Curtir",
"unlike": "Descurtir",
"viewOriginal": "Ver original",
"liked": "Curtido",
"boosted": "Compartilhado",
"likeError": "Não foi possível curtir este post",
"boostError": "Não foi possível compartilhar este post"
},
"post": {
"title": "Detalhe do post",
"notFound": "Post não encontrado ou não mais disponível.",
"openExternal": "Abrir na instância original",
"parentPosts": "Thread",
"replies": "Respostas",
"back": "Voltar para a linha do tempo",
"loadingThread": "Carregando thread...",
"threadError": "Não foi possível carregar a thread completa"
},
"resolve": {
"placeholder": "Cole uma URL do fediverse ou um identificador @usuario@dominio…",
"label": "Buscar um post ou conta do fediverse",
"button": "Buscar",
"notFoundTitle": "Não encontrado",
"notFound": "Não foi possível encontrar este post ou conta. A URL pode ser inválida, o servidor pode estar indisponível ou o conteúdo pode ter sido excluído.",
"followersLabel": "seguidores"
},
"linkPreview": {
"label": "Pré-visualização do link"
},
"explore": {
"title": "Explorar",
"description": "Navegue por linhas do tempo públicas de instâncias remotas compatíveis com o Mastodon.",
"instancePlaceholder": "Digite o hostname de uma instância, ex. mastodon.social",
"browse": "Navegar",
"local": "Local",
"federated": "Federada",
"loadError": "Não foi possível carregar a linha do tempo desta instância. Ela pode estar indisponível ou não suportar a API do Mastodon.",
"timeout": "A requisição expirou. A instância pode estar lenta ou indisponível.",
"noResults": "Nenhum post encontrado na linha do tempo pública desta instância.",
"invalidInstance": "Hostname de instância inválido. Por favor, digite um nome de domínio válido.",
"mauLabel": "MAU",
"timelineSupported": "Linha do tempo pública disponível",
"timelineUnsupported": "Linha do tempo pública indisponível",
"hashtagLabel": "Hashtag (opcional)",
"hashtagPlaceholder": "ex. indieweb",
"hashtagHint": "Filtrar resultados por uma hashtag específica",
"tabs": {
"label": "Abas de exploração",
"search": "Buscar",
"pinAsTab": "Fixar como aba",
"pinned": "Fixadas",
"remove": "Remover aba",
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo",
"addHashtag": "Adicionar aba de hashtag",
"hashtagTabPlaceholder": "Digite a hashtag",
"addTab": "Adicionar",
"retry": "Tentar novamente",
"noInstances": "Fixe algumas instâncias primeiro para usar abas de hashtag.",
"sources": "Buscando #%s em %d instância",
"sources_plural": "Buscando #%s em %d instâncias",
"sourcesPartial": "%d de %d instâncias responderam"
}
},
"tagTimeline": {
"postsTagged": "%d post",
"postsTagged_plural": "%d posts",
"noPosts": "Nenhum post com #%s encontrado na sua linha do tempo.",
"followTag": "Seguir hashtag",
"unfollowTag": "Deixar de seguir hashtag",
"following": "Seguindo"
},
"pagination": {
"newer": "← Mais recentes",
"older": "Mais antigos →",
"loadMore": "Carregar mais",
"loading": "Carregando…",
"noMore": "Você está em dia."
}
},
"myProfile": {
"title": "Meu perfil",
"posts": "posts",
"editProfile": "Editar perfil",
"empty": "Nada aqui ainda.",
"tabs": {
"posts": "Posts",
"replies": "Respostas",
"likes": "Curtidas",
"boosts": "Compartilhamentos"
}
},
"poll": {
"voters": "votantes",
"votes": "votos",
"closed": "Enquete encerrada",
"endsAt": "Termina"
},
"federation": {
"deleteSuccess": "Atividade de exclusão enviada aos seguidores",
"deleteButton": "Excluir do fediverse"
},
"federationMgmt": {
"title": "Federação",
"collections": "Saúde das coleções",
"quickActions": "Ações rápidas",
"broadcastActor": "Transmitir atualização do ator",
"debugDashboard": "Painel de depuração",
"objectLookup": "Busca de objeto",
"lookupPlaceholder": "URL ou identificador @usuario@dominio…",
"lookup": "Buscar",
"lookupLoading": "Resolvendo…",
"postActions": "Federação de posts",
"viewJson": "JSON",
"rebroadcast": "Re-transmitir atividade Create",
"rebroadcastShort": "Reenviar",
"broadcastDelete": "Transmitir atividade Delete",
"deleteShort": "Excluir",
"noPosts": "Nenhum post encontrado.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Atividade recente",
"viewAllActivities": "Ver todas as atividades →"
},
"reports": {
"sentReport": "fez uma denúncia",
"title": "Denúncias"
}
}
}

358
locales/pt.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Seguidores",
"following": "A seguir",
"activities": "Registo de atividade",
"featured": "Publicações fixadas",
"featuredTags": "Etiquetas em destaque",
"recentActivity": "Atividade recente",
"noActivity": "Ainda sem atividade. Assim que o teu ator estiver federado, as interações aparecerão aqui.",
"noFollowers": "Ainda sem seguidores.",
"noFollowing": "Ainda não segues ninguém.",
"pendingFollows": "Pendentes",
"noPendingFollows": "Sem pedidos de seguimento pendentes.",
"approve": "Aprovar",
"reject": "Rejeitar",
"followApproved": "Pedido de seguimento aprovado.",
"followRejected": "Pedido de seguimento rejeitado.",
"followRequest": "pediu para te seguir",
"followerCount": "%d seguidor",
"followerCount_plural": "%d seguidores",
"followingCount": "%d a seguir",
"followedAt": "A seguir desde",
"source": "Fonte",
"sourceImport": "Importação do Mastodon",
"sourceManual": "Manual",
"sourceFederation": "Federação",
"sourceRefollowPending": "Re-seguimento pendente",
"sourceRefollowFailed": "Re-seguimento falhado",
"direction": "Direção",
"directionInbound": "Recebido",
"directionOutbound": "Enviado",
"profile": {
"title": "Perfil",
"intro": "Edita a forma como o teu ator aparece para outros utilizadores do fediverse. As alterações entram em vigor imediatamente.",
"nameLabel": "Nome de exibição",
"nameHint": "O teu nome tal como é apresentado no teu perfil do fediverse",
"summaryLabel": "Biografia",
"summaryHint": "Uma breve descrição sobre ti. HTML é permitido.",
"urlLabel": "URL do sítio web",
"urlHint": "O endereço do teu sítio web, apresentado como ligação no teu perfil",
"iconLabel": "URL do avatar",
"iconHint": "URL da tua imagem de perfil (quadrada, recomendado pelo menos 400x400px)",
"imageLabel": "URL da imagem de cabeçalho",
"imageHint": "URL de uma imagem de banner apresentada no topo do teu perfil",
"manualApprovalLabel": "Aprovar seguidores manualmente",
"manualApprovalHint": "Quando ativado, os pedidos de seguimento requerem a tua aprovação antes de entrarem em vigor",
"actorTypeLabel": "Tipo de ator",
"actorTypeHint": "Como a tua conta aparece no fediverse. Person para indivíduos, Service para bots ou contas automatizadas, Organization para grupos ou empresas.",
"linksLabel": "Ligações do perfil",
"linksHint": "Ligações apresentadas no teu perfil do fediverse. Adiciona o teu sítio web, contas sociais ou outros URLs. As páginas que apontem de volta com rel=\"me\" serão apresentadas como verificadas no Mastodon.",
"linkNameLabel": "Rótulo",
"linkValueLabel": "URL",
"addLink": "Adicionar ligação",
"removeLink": "Remover",
"authorizedFetchLabel": "Requerer obtenção autorizada (modo seguro)",
"authorizedFetchHint": "Quando ativado, apenas servidores com assinaturas HTTP válidas podem obter o teu ator e coleções. Isto melhora a privacidade mas pode reduzir a compatibilidade com alguns clientes.",
"save": "Guardar perfil",
"saved": "Perfil guardado. As alterações estão agora visíveis no fediverse.",
"public": {
"followPrompt": "Segue-me no fediverse",
"copyHandle": "Copiar identificador",
"copied": "Copiado!",
"pinnedPosts": "Publicações fixadas",
"recentPosts": "Publicações recentes",
"joinedDate": "Aderiu",
"posts": "Publicações",
"followers": "Seguidores",
"following": "A seguir",
"viewOnSite": "Ver no sítio"
},
"remote": {
"follow": "Seguir",
"unfollow": "Deixar de seguir",
"viewOn": "Ver em",
"postsTitle": "Publicações",
"noPosts": "Ainda sem publicações desta conta.",
"followToSee": "Segue esta conta para veres as suas publicações na tua cronologia.",
"notFound": "Não foi possível encontrar esta conta. Pode ter sido eliminada ou o servidor pode estar indisponível."
}
},
"migrate": {
"title": "Migração do Mastodon",
"intro": "Este guia acompanha-te na transferência da tua identidade do Mastodon para o teu sítio IndieWeb. Completa cada passo por ordem — os teus seguidores existentes serão notificados e poderão seguir-te automaticamente.",
"step1Title": "Passo 1 — Associar a tua conta antiga",
"step1Desc": "Informa o fediverse que a tua conta antiga do Mastodon e este sítio pertencem à mesma pessoa. Isto define a propriedade <code>alsoKnownAs</code> no teu ator ActivityPub, que o Mastodon verifica antes de permitir uma transferência.",
"aliasLabel": "URL da conta antiga do Mastodon",
"aliasHint": "O URL completo do teu perfil do Mastodon, p. ex. https://mstdn.social/users/rmdes",
"aliasSave": "Guardar alias",
"aliasCurrent": "Alias atual",
"aliasNone": "Nenhum alias configurado.",
"step2Title": "Passo 2 — Importar o teu grafo social",
"step2Desc": "Carrega os ficheiros CSV da tua exportação de dados do Mastodon para trazer as tuas ligações. Vai à tua instância do Mastodon → Preferências → Importar e exportar → Exportação de dados para os descarregar.",
"importLegend": "O que importar",
"fileLabel": "Ficheiro CSV",
"fileHint": "Seleciona um ficheiro CSV da tua exportação de dados do Mastodon (p. ex. following_accounts.csv ou followers.csv)",
"importButton": "Importar",
"importFollowing": "Lista de seguidos",
"importFollowingHint": "Contas que segues — aparecerão imediatamente na tua lista de A seguir",
"importFollowers": "Lista de seguidores",
"importFollowersHint": "Os teus seguidores atuais — serão registados como pendentes até te seguirem novamente após a transferência no passo 3",
"step3Title": "Passo 3 — Transferir a tua conta",
"step3Desc": "Depois de guardares o teu alias e importares os teus dados, vai à tua instância do Mastodon → Preferências → Conta → <strong>Transferir para outra conta</strong>. Introduz o teu novo identificador do fediverse e confirma. O Mastodon notificará todos os teus seguidores, e aqueles cujos servidores o suportem seguir-te-ão automaticamente aqui. Este passo é irreversível — a tua conta antiga tornar-se-á num redirecionamento.",
"errorNoFile": "Por favor, seleciona um ficheiro CSV antes de importar.",
"success": "Importados %d seguidos, %d seguidores (%d falhados).",
"failedList": "Não foi possível resolver: %s",
"failedListSummary": "Identificadores falhados",
"aliasSuccess": "Alias guardado — o teu documento de ator inclui agora esta conta como alsoKnownAs."
},
"refollow": {
"title": "Re-seguimento em lote",
"progress": "Progresso do re-seguimento",
"remaining": "Restantes",
"awaitingAccept": "A aguardar aceitação",
"accepted": "Aceite",
"failed": "Falhado",
"pause": "Pausar",
"resume": "Retomar",
"status": {
"idle": "Inativo",
"running": "Em execução",
"paused": "Pausado",
"completed": "Concluído"
}
},
"moderation": {
"title": "Moderação",
"blockedTitle": "Contas bloqueadas",
"mutedActorsTitle": "Contas silenciadas",
"mutedKeywordsTitle": "Palavras-chave silenciadas",
"noBlocked": "Sem contas bloqueadas.",
"noMutedActors": "Sem contas silenciadas.",
"noMutedKeywords": "Sem palavras-chave silenciadas.",
"unblock": "Desbloquear",
"unmute": "Remover silêncio",
"addKeywordTitle": "Adicionar palavra-chave silenciada",
"keywordPlaceholder": "Introduz palavra-chave ou frase…",
"addKeyword": "Adicionar",
"muteActor": "Silenciar",
"blockActor": "Bloquear",
"filterModeTitle": "Modo de filtragem",
"filterModeHint": "Escolhe como o conteúdo silenciado é tratado na tua cronologia. As contas bloqueadas são sempre ocultadas.",
"filterModeHide": "Ocultar — remover da cronologia",
"filterModeWarn": "Avisar — mostrar atrás de aviso de conteúdo",
"cwMutedAccount": "Conta silenciada",
"cwMutedKeyword": "Palavra-chave silenciada:",
"cwFiltered": "Conteúdo filtrado"
},
"compose": {
"title": "Redigir resposta",
"placeholder": "Escreve a tua resposta…",
"syndicateLabel": "Sindicar para",
"submitMicropub": "Publicar resposta",
"cancel": "Cancelar",
"errorEmpty": "O conteúdo da resposta não pode estar vazio",
"visibilityLabel": "Visibilidade",
"visibilityPublic": "Público",
"visibilityUnlisted": "Não listado",
"visibilityFollowers": "Apenas seguidores",
"cwLabel": "Aviso de conteúdo",
"cwPlaceholder": "Escreve o teu aviso aqui…"
},
"notifications": {
"title": "Notificações",
"empty": "Ainda sem notificações. As interações de outros utilizadores do fediverse aparecerão aqui.",
"liked": "gostou da tua publicação",
"boostedPost": "partilhou a tua publicação",
"followedYou": "seguiu-te",
"repliedTo": "respondeu à tua publicação",
"mentionedYou": "mencionou-te",
"markAllRead": "Marcar tudo como lido",
"clearAll": "Limpar tudo",
"clearConfirm": "Eliminar todas as notificações? Esta ação não pode ser desfeita.",
"dismiss": "Dispensar",
"viewThread": "Ver tópico",
"tabs": {
"all": "Todas",
"replies": "Respostas",
"likes": "Gostos",
"boosts": "Partilhas",
"follows": "Seguimentos",
"dms": "MPs",
"reports": "Denúncias"
},
"emptyTab": "Ainda sem notificações de %s."
},
"messages": {
"title": "Mensagens",
"empty": "Ainda sem mensagens. As mensagens diretas de outros utilizadores do fediverse aparecerão aqui.",
"allConversations": "Todas as conversas",
"compose": "Nova mensagem",
"send": "Enviar mensagem",
"delete": "Eliminar",
"markAllRead": "Marcar tudo como lido",
"clearAll": "Limpar tudo",
"clearConfirm": "Eliminar todas as mensagens? Esta ação não pode ser desfeita.",
"recipientLabel": "Para",
"recipientPlaceholder": "@utilizador@instancia.social",
"placeholder": "Escreve a tua mensagem...",
"sentTo": "Para",
"replyingTo": "Em resposta a",
"sentYouDM": "enviou-te uma mensagem direta",
"viewMessage": "Ver mensagem",
"errorEmpty": "O conteúdo da mensagem não pode estar vazio.",
"errorNoRecipient": "Por favor, introduz um destinatário.",
"errorRecipientNotFound": "Não foi possível encontrar esse utilizador. Tenta um identificador completo @utilizador@domínio."
},
"reader": {
"title": "Leitor",
"tabs": {
"all": "Tudo",
"notes": "Notas",
"articles": "Artigos",
"replies": "Respostas",
"boosts": "Partilhas",
"media": "Média"
},
"empty": "A tua cronologia está vazia. Segue algumas contas para veres as suas publicações aqui.",
"boosted": "partilhou",
"replyingTo": "Em resposta a",
"showContent": "Mostrar conteúdo",
"hideContent": "Ocultar conteúdo",
"sensitiveContent": "Conteúdo sensível",
"videoNotSupported": "O teu navegador não suporta o elemento de vídeo.",
"audioNotSupported": "O teu navegador não suporta o elemento de áudio.",
"actions": {
"reply": "Responder",
"boost": "Partilhar",
"unboost": "Anular partilha",
"like": "Gostar",
"unlike": "Anular gosto",
"viewOriginal": "Ver original",
"liked": "Gostou",
"boosted": "Partilhado",
"likeError": "Não foi possível gostar desta publicação",
"boostError": "Não foi possível partilhar esta publicação"
},
"post": {
"title": "Detalhe da publicação",
"notFound": "Publicação não encontrada ou já não disponível.",
"openExternal": "Abrir na instância original",
"parentPosts": "Tópico",
"replies": "Respostas",
"back": "Voltar à cronologia",
"loadingThread": "A carregar tópico...",
"threadError": "Não foi possível carregar o tópico completo"
},
"resolve": {
"placeholder": "Cola um URL do fediverse ou um identificador @utilizador@domínio…",
"label": "Procurar uma publicação ou conta do fediverse",
"button": "Procurar",
"notFoundTitle": "Não encontrado",
"notFound": "Não foi possível encontrar esta publicação ou conta. O URL pode ser inválido, o servidor pode estar indisponível ou o conteúdo pode ter sido eliminado.",
"followersLabel": "seguidores"
},
"linkPreview": {
"label": "Pré-visualização da ligação"
},
"explore": {
"title": "Explorar",
"description": "Navega por cronologias públicas de instâncias remotas compatíveis com o Mastodon.",
"instancePlaceholder": "Introduz o nome de anfitrião de uma instância, p. ex. mastodon.social",
"browse": "Navegar",
"local": "Local",
"federated": "Federada",
"loadError": "Não foi possível carregar a cronologia desta instância. Pode estar indisponível ou não suportar a API do Mastodon.",
"timeout": "O pedido expirou. A instância pode estar lenta ou indisponível.",
"noResults": "Sem publicações encontradas na cronologia pública desta instância.",
"invalidInstance": "Nome de anfitrião de instância inválido. Por favor, introduz um nome de domínio válido.",
"mauLabel": "MAU",
"timelineSupported": "Cronologia pública disponível",
"timelineUnsupported": "Cronologia pública indisponível",
"hashtagLabel": "Hashtag (opcional)",
"hashtagPlaceholder": "p. ex. indieweb",
"hashtagHint": "Filtrar resultados por uma hashtag específica",
"tabs": {
"label": "Separadores de exploração",
"search": "Procurar",
"pinAsTab": "Fixar como separador",
"pinned": "Fixados",
"remove": "Remover separador",
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo",
"addHashtag": "Adicionar separador de hashtag",
"hashtagTabPlaceholder": "Introduz hashtag",
"addTab": "Adicionar",
"retry": "Tentar novamente",
"noInstances": "Fixa primeiro algumas instâncias para usares separadores de hashtag.",
"sources": "A procurar #%s em %d instância",
"sources_plural": "A procurar #%s em %d instâncias",
"sourcesPartial": "%d de %d instâncias responderam"
}
},
"tagTimeline": {
"postsTagged": "%d publicação",
"postsTagged_plural": "%d publicações",
"noPosts": "Sem publicações com #%s encontradas na tua cronologia.",
"followTag": "Seguir hashtag",
"unfollowTag": "Deixar de seguir hashtag",
"following": "A seguir"
},
"pagination": {
"newer": "← Mais recentes",
"older": "Mais antigas →",
"loadMore": "Carregar mais",
"loading": "A carregar…",
"noMore": "Estás em dia."
}
},
"myProfile": {
"title": "O meu perfil",
"posts": "publicações",
"editProfile": "Editar perfil",
"empty": "Ainda nada aqui.",
"tabs": {
"posts": "Publicações",
"replies": "Respostas",
"likes": "Gostos",
"boosts": "Partilhas"
}
},
"poll": {
"voters": "votantes",
"votes": "votos",
"closed": "Sondagem encerrada",
"endsAt": "Termina"
},
"federation": {
"deleteSuccess": "Atividade de eliminação enviada aos seguidores",
"deleteButton": "Eliminar do fediverse"
},
"federationMgmt": {
"title": "Federação",
"collections": "Estado das coleções",
"quickActions": "Ações rápidas",
"broadcastActor": "Difundir atualização do ator",
"debugDashboard": "Painel de depuração",
"objectLookup": "Pesquisa de objeto",
"lookupPlaceholder": "URL ou identificador @utilizador@domínio…",
"lookup": "Procurar",
"lookupLoading": "A resolver…",
"postActions": "Federação de publicações",
"viewJson": "JSON",
"rebroadcast": "Re-difundir atividade Create",
"rebroadcastShort": "Reenviar",
"broadcastDelete": "Difundir atividade Delete",
"deleteShort": "Eliminar",
"noPosts": "Sem publicações encontradas.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Atividade recente",
"viewAllActivities": "Ver todas as atividades →"
},
"reports": {
"sentReport": "apresentou uma denúncia",
"title": "Denúncias"
}
}
}

358
locales/sr.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Пратиоци",
"following": "Праћени",
"activities": "Дневник активности",
"featured": "Закачени објаве",
"featuredTags": "Истакнуте ознаке",
"recentActivity": "Недавна активност",
"noActivity": "Још нема активности. Када ваш актер буде федерисан, интеракције ће се појавити овде.",
"noFollowers": "Још нема пратилаца.",
"noFollowing": "Још не пратите никога.",
"pendingFollows": "На чекању",
"noPendingFollows": "Нема захтева за праћење на чекању.",
"approve": "Одобри",
"reject": "Одбиј",
"followApproved": "Захтев за праћење одобрен.",
"followRejected": "Захтев за праћење одбијен.",
"followRequest": "жели да вас прати",
"followerCount": "%d пратилац",
"followerCount_plural": "%d пратилаца",
"followingCount": "%d праћених",
"followedAt": "Прати од",
"source": "Извор",
"sourceImport": "Mastodon увоз",
"sourceManual": "Ручно",
"sourceFederation": "Федерација",
"sourceRefollowPending": "Поновно праћење на чекању",
"sourceRefollowFailed": "Поновно праћење неуспешно",
"direction": "Смер",
"directionInbound": "Примљено",
"directionOutbound": "Послато",
"profile": {
"title": "Профил",
"intro": "Уредите како ваш актер изгледа другим корисницима fediverse-а. Промене одмах ступају на снагу.",
"nameLabel": "Приказано име",
"nameHint": "Ваше име како се приказује на вашем fediverse профилу",
"summaryLabel": "Биографија",
"summaryHint": "Кратак опис о себи. HTML је дозвољен.",
"urlLabel": "URL веб сајта",
"urlHint": "Адреса вашег веб сајта, приказана као линк на вашем профилу",
"iconLabel": "URL аватара",
"iconHint": "URL ваше профилне слике (квадратна, препоручено најмање 400x400px)",
"imageLabel": "URL слике заглавља",
"imageHint": "URL банер слике приказане на врху вашег профила",
"manualApprovalLabel": "Ручно одобравање пратилаца",
"manualApprovalHint": "Када је омогућено, захтеви за праћење захтевају вашу сагласност пре него што ступе на снагу",
"actorTypeLabel": "Тип актера",
"actorTypeHint": "Како ваш налог изгледа у fediverse-у. Person за појединце, Service за ботове или аутоматизоване налоге, Organization за групе или компаније.",
"linksLabel": "Линкови профила",
"linksHint": "Линкови приказани на вашем fediverse профилу. Додајте свој веб сајт, друштвене налоге или друге URL-ове. Странице које линкују назад са rel=\"me\" биће приказане као верификоване на Mastodon-у.",
"linkNameLabel": "Ознака",
"linkValueLabel": "URL",
"addLink": "Додај линк",
"removeLink": "Уклони",
"authorizedFetchLabel": "Захтевај ауторизовано преузимање (безбедни режим)",
"authorizedFetchHint": "Када је омогућено, само сервери са важећим HTTP потписима могу преузети вашег актера и колекције. Ово побољшава приватност али може смањити компатибилност са неким клијентима.",
"save": "Сачувај профил",
"saved": "Профил сачуван. Промене су сада видљиве у fediverse-у.",
"public": {
"followPrompt": "Пратите ме на fediverse-у",
"copyHandle": "Копирај идентификатор",
"copied": "Копирано!",
"pinnedPosts": "Закачене објаве",
"recentPosts": "Недавне објаве",
"joinedDate": "Придружио/ла се",
"posts": "Објаве",
"followers": "Пратиоци",
"following": "Праћени",
"viewOnSite": "Погледај на сајту"
},
"remote": {
"follow": "Прати",
"unfollow": "Отпрати",
"viewOn": "Погледај на",
"postsTitle": "Објаве",
"noPosts": "Још нема објава са овог налога.",
"followToSee": "Пратите овај налог да бисте видели њихове објаве на вашој временској линији.",
"notFound": "Није могуће пронаћи овај налог. Можда је обрисан или је сервер недоступан."
}
},
"migrate": {
"title": "Mastodon миграција",
"intro": "Овај водич вас води кроз пребацивање вашег Mastodon идентитета на ваш IndieWeb сајт. Завршите сваки корак по реду — ваши постојећи пратиоци биће обавештени и могу вас аутоматски поново пратити.",
"step1Title": "Корак 1 — Повежите свој стари налог",
"step1Desc": "Обавестите fediverse да ваш стари Mastodon налог и овај сајт припадају истој особи. Ово поставља својство <code>alsoKnownAs</code> на вашем ActivityPub актеру, које Mastodon проверава пре него што дозволи пребацивање.",
"aliasLabel": "URL старог Mastodon налога",
"aliasHint": "Пуни URL вашег Mastodon профила, нпр. https://mstdn.social/users/rmdes",
"aliasSave": "Сачувај алијас",
"aliasCurrent": "Тренутни алијас",
"aliasNone": "Алијас још није подешен.",
"step2Title": "Корак 2 — Увезите свој друштвени граф",
"step2Desc": "Отпремите CSV фајлове из вашег Mastodon извоза података да бисте пренели своје везе. Идите на своју Mastodon инстанцу → Подешавања → Увоз и извоз → Извоз података да их преузмете.",
"importLegend": "Шта увести",
"fileLabel": "CSV фајл",
"fileHint": "Изаберите CSV фајл из вашег Mastodon извоза података (нпр. following_accounts.csv или followers.csv)",
"importButton": "Увези",
"importFollowing": "Листа праћених",
"importFollowingHint": "Налози које пратите — одмах ће се појавити на вашој листи Праћених",
"importFollowers": "Листа пратилаца",
"importFollowersHint": "Ваши тренутни пратиоци — биће забележени као на чекању док вас поново не запрате после пребацивања у кораку 3",
"step3Title": "Корак 3 — Пребаците свој налог",
"step3Desc": "Када сачувате свој алијас и увезете своје податке, идите на своју Mastodon инстанцу → Подешавања → Налог → <strong>Пребаци на други налог</strong>. Унесите свој нови fediverse идентификатор и потврдите. Mastodon ће обавестити све ваше пратиоце, а они чији сервери то подржавају аутоматски ће вас пратити овде. Овај корак је неповратан — ваш стари налог ће постати преусмеравање.",
"errorNoFile": "Молимо изаберите CSV фајл пре увоза.",
"success": "Увезено %d праћених, %d пратилаца (%d неуспешних).",
"failedList": "Није могуће резолвирати: %s",
"failedListSummary": "Неуспешни идентификатори",
"aliasSuccess": "Алијас сачуван — ваш документ актера сада укључује овај налог као alsoKnownAs."
},
"refollow": {
"title": "Групно поновно праћење",
"progress": "Напредак поновног праћења",
"remaining": "Преостало",
"awaitingAccept": "Чека се прихватање",
"accepted": "Прихваћено",
"failed": "Неуспешно",
"pause": "Паузирај",
"resume": "Настави",
"status": {
"idle": "Неактивно",
"running": "У току",
"paused": "Паузирано",
"completed": "Завршено"
}
},
"moderation": {
"title": "Модерација",
"blockedTitle": "Блокирани налози",
"mutedActorsTitle": "Утишани налози",
"mutedKeywordsTitle": "Утишане кључне речи",
"noBlocked": "Нема блокираних налога.",
"noMutedActors": "Нема утишаних налога.",
"noMutedKeywords": "Нема утишаних кључних речи.",
"unblock": "Деблокирај",
"unmute": "Укључи",
"addKeywordTitle": "Додај утишану кључну реч",
"keywordPlaceholder": "Унесите кључну реч или фразу…",
"addKeyword": "Додај",
"muteActor": "Утишај",
"blockActor": "Блокирај",
"filterModeTitle": "Режим филтрирања",
"filterModeHint": "Изаберите како се утишани садржај обрађује на вашој временској линији. Блокирани налози су увек скривени.",
"filterModeHide": "Сакриј — уклони са временске линије",
"filterModeWarn": "Упозори — прикажи иза упозорења о садржају",
"cwMutedAccount": "Утишани налог",
"cwMutedKeyword": "Утишана кључна реч:",
"cwFiltered": "Филтрирани садржај"
},
"compose": {
"title": "Напиши одговор",
"placeholder": "Напишите свој одговор…",
"syndicateLabel": "Синдицирај на",
"submitMicropub": "Објави одговор",
"cancel": "Откажи",
"errorEmpty": "Садржај одговора не може бити празан",
"visibilityLabel": "Видљивост",
"visibilityPublic": "Јавно",
"visibilityUnlisted": "Неизлистано",
"visibilityFollowers": "Само пратиоци",
"cwLabel": "Упозорење о садржају",
"cwPlaceholder": "Напишите своје упозорење овде…"
},
"notifications": {
"title": "Обавештења",
"empty": "Још нема обавештења. Интеракције од других корисника fediverse-а ће се појавити овде.",
"liked": "допала се ваша објава",
"boostedPost": "поделио/ла вашу објаву",
"followedYou": "прати вас",
"repliedTo": "одговорио/ла на вашу објаву",
"mentionedYou": "вас је поменуо/ла",
"markAllRead": "Означи све као прочитано",
"clearAll": "Обриши све",
"clearConfirm": "Обрисати сва обавештења? Ово се не може поништити.",
"dismiss": "Одбаци",
"viewThread": "Погледај нит",
"tabs": {
"all": "Све",
"replies": "Одговори",
"likes": "Свиђања",
"boosts": "Дељења",
"follows": "Праћења",
"dms": "ПП",
"reports": "Пријаве"
},
"emptyTab": "Још нема %s обавештења."
},
"messages": {
"title": "Поруке",
"empty": "Још нема порука. Директне поруке од других корисника fediverse-а ће се појавити овде.",
"allConversations": "Сви разговори",
"compose": "Нова порука",
"send": "Пошаљи поруку",
"delete": "Обриши",
"markAllRead": "Означи све као прочитано",
"clearAll": "Обриши све",
"clearConfirm": "Обрисати све поруке? Ово се не може поништити.",
"recipientLabel": "За",
"recipientPlaceholder": "@корисник@инстанца.social",
"placeholder": "Напишите своју поруку...",
"sentTo": "За",
"replyingTo": "Одговор на",
"sentYouDM": "вам је послао/ла директну поруку",
"viewMessage": "Погледај поруку",
"errorEmpty": "Садржај поруке не може бити празан.",
"errorNoRecipient": "Молимо унесите примаоца.",
"errorRecipientNotFound": "Није могуће пронаћи тог корисника. Покушајте са пуним @корисник@домен идентификатором."
},
"reader": {
"title": "Читач",
"tabs": {
"all": "Све",
"notes": "Белешке",
"articles": "Чланци",
"replies": "Одговори",
"boosts": "Дељења",
"media": "Медији"
},
"empty": "Ваша временска линија је празна. Пратите неке налоге да бисте видели њихове објаве овде.",
"boosted": "поделио/ла",
"replyingTo": "Одговор на",
"showContent": "Прикажи садржај",
"hideContent": "Сакриј садржај",
"sensitiveContent": "Осетљив садржај",
"videoNotSupported": "Ваш прегледач не подржава видео елемент.",
"audioNotSupported": "Ваш прегледач не подржава аудио елемент.",
"actions": {
"reply": "Одговори",
"boost": "Подели",
"unboost": "Поништи дељење",
"like": "Свиђа ми се",
"unlike": "Поништи свиђање",
"viewOriginal": "Погледај оригинал",
"liked": "Свиђа се",
"boosted": "Подељено",
"likeError": "Није могуће означити ову објаву свиђањем",
"boostError": "Није могуће поделити ову објаву"
},
"post": {
"title": "Детаљи објаве",
"notFound": "Објава није пронађена или више није доступна.",
"openExternal": "Отвори на оригиналној инстанци",
"parentPosts": "Нит",
"replies": "Одговори",
"back": "Назад на временску линију",
"loadingThread": "Учитавање нити...",
"threadError": "Није могуће учитати целу нит"
},
"resolve": {
"placeholder": "Налепите fediverse URL или @корисник@домен идентификатор…",
"label": "Претражите fediverse објаву или налог",
"button": "Претражи",
"notFoundTitle": "Није пронађено",
"notFound": "Није могуће пронаћи ову објаву или налог. URL може бити неважећи, сервер може бити недоступан или је садржај можда обрисан.",
"followersLabel": "пратилаца"
},
"linkPreview": {
"label": "Преглед линка"
},
"explore": {
"title": "Истражите",
"description": "Прегледајте јавне временске линије са удаљених инстанци компатибилних са Mastodon-ом.",
"instancePlaceholder": "Унесите назив хоста инстанце, нпр. mastodon.social",
"browse": "Прегледај",
"local": "Локално",
"federated": "Федерисано",
"loadError": "Није могуће учитати временску линију са ове инстанце. Можда је недоступна или не подржава Mastodon API.",
"timeout": "Захтев је истекао. Инстанца може бити спора или недоступна.",
"noResults": "Нису пронађене објаве на јавној временској линији ове инстанце.",
"invalidInstance": "Неважећи назив хоста инстанце. Молимо унесите важећи назив домена.",
"mauLabel": "MAU",
"timelineSupported": "Јавна временска линија доступна",
"timelineUnsupported": "Јавна временска линија недоступна",
"hashtagLabel": "Хештег (опционо)",
"hashtagPlaceholder": "нпр. indieweb",
"hashtagHint": "Филтрирајте резултате по одређеном хештегу",
"tabs": {
"label": "Картице за истраживање",
"search": "Претражи",
"pinAsTab": "Закачи као картицу",
"pinned": "Закачене",
"remove": "Уклони картицу",
"moveUp": "Помери горе",
"moveDown": "Помери доле",
"addHashtag": "Додај картицу хештега",
"hashtagTabPlaceholder": "Унесите хештег",
"addTab": "Додај",
"retry": "Покушај поново",
"noInstances": "Закачите прво неке инстанце да бисте користили картице хештега.",
"sources": "Претрага #%s на %d инстанци",
"sources_plural": "Претрага #%s на %d инстанци",
"sourcesPartial": "%d од %d инстанци је одговорило"
}
},
"tagTimeline": {
"postsTagged": "%d објава",
"postsTagged_plural": "%d објава",
"noPosts": "Нису пронађене објаве са #%s на вашој временској линији.",
"followTag": "Прати хештег",
"unfollowTag": "Отпрати хештег",
"following": "Праћено"
},
"pagination": {
"newer": "← Новије",
"older": "Старије →",
"loadMore": "Учитај више",
"loading": "Учитавање…",
"noMore": "У току сте са свим."
}
},
"myProfile": {
"title": "Мој профил",
"posts": "објаве",
"editProfile": "Уреди профил",
"empty": "Овде још нема ничега.",
"tabs": {
"posts": "Објаве",
"replies": "Одговори",
"likes": "Свиђања",
"boosts": "Дељења"
}
},
"poll": {
"voters": "гласача",
"votes": "гласова",
"closed": "Анкета затворена",
"endsAt": "Завршава се"
},
"federation": {
"deleteSuccess": "Активност брисања послата пратиоцима",
"deleteButton": "Обриши из fediverse-а"
},
"federationMgmt": {
"title": "Федерација",
"collections": "Стање колекција",
"quickActions": "Брзе акције",
"broadcastActor": "Емитуј ажурирање актера",
"debugDashboard": "Контролна табла за дебаговање",
"objectLookup": "Претрага објекта",
"lookupPlaceholder": "URL или @корисник@домен идентификатор…",
"lookup": "Претражи",
"lookupLoading": "Резолвирање…",
"postActions": "Федерација објава",
"viewJson": "JSON",
"rebroadcast": "Поново емитуј Create активност",
"rebroadcastShort": "Поново пошаљи",
"broadcastDelete": "Емитуј Delete активност",
"deleteShort": "Обриши",
"noPosts": "Нису пронађене објаве.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Недавна активност",
"viewAllActivities": "Погледај све активности →"
},
"reports": {
"sentReport": "је поднео/ла пријаву",
"title": "Пријаве"
}
}
}

358
locales/sv.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Följare",
"following": "Följer",
"activities": "Aktivitetslogg",
"featured": "Fästa inlägg",
"featuredTags": "Utvalda taggar",
"recentActivity": "Senaste aktivitet",
"noActivity": "Ingen aktivitet ännu. När din aktör är federerad kommer interaktioner att visas här.",
"noFollowers": "Inga följare ännu.",
"noFollowing": "Följer ingen ännu.",
"pendingFollows": "Väntande",
"noPendingFollows": "Inga väntande följförfrågningar.",
"approve": "Godkänn",
"reject": "Avvisa",
"followApproved": "Följförfrågan godkänd.",
"followRejected": "Följförfrågan avvisad.",
"followRequest": "vill följa dig",
"followerCount": "%d följare",
"followerCount_plural": "%d följare",
"followingCount": "%d följer",
"followedAt": "Följer sedan",
"source": "Källa",
"sourceImport": "Mastodon-import",
"sourceManual": "Manuell",
"sourceFederation": "Federation",
"sourceRefollowPending": "Omföljning väntar",
"sourceRefollowFailed": "Omföljning misslyckades",
"direction": "Riktning",
"directionInbound": "Mottaget",
"directionOutbound": "Skickat",
"profile": {
"title": "Profil",
"intro": "Redigera hur din aktör visas för andra fediverse-användare. Ändringar träder i kraft omedelbart.",
"nameLabel": "Visningsnamn",
"nameHint": "Ditt namn som det visas på din fediverse-profil",
"summaryLabel": "Biografi",
"summaryHint": "En kort beskrivning av dig själv. HTML är tillåtet.",
"urlLabel": "Webbplatsens URL",
"urlHint": "Din webbadress, visad som en länk på din profil",
"iconLabel": "Avatar-URL",
"iconHint": "URL till din profilbild (kvadratisk, minst 400x400px rekommenderas)",
"imageLabel": "Rubrikbildens URL",
"imageHint": "URL till en bannerbild som visas högst upp på din profil",
"manualApprovalLabel": "Godkänn följare manuellt",
"manualApprovalHint": "När aktiverat kräver följförfrågningar ditt godkännande innan de träder i kraft",
"actorTypeLabel": "Aktörstyp",
"actorTypeHint": "Hur ditt konto visas i fediverse. Person för individer, Service för botar eller automatiserade konton, Organization för grupper eller företag.",
"linksLabel": "Profillänkar",
"linksHint": "Länkar som visas på din fediverse-profil. Lägg till din webbplats, sociala konton eller andra URL:er. Sidor som länkar tillbaka med rel=\"me\" visas som verifierade på Mastodon.",
"linkNameLabel": "Etikett",
"linkValueLabel": "URL",
"addLink": "Lägg till länk",
"removeLink": "Ta bort",
"authorizedFetchLabel": "Kräv auktoriserad hämtning (säkert läge)",
"authorizedFetchHint": "När aktiverat kan bara servrar med giltiga HTTP-signaturer hämta din aktör och samlingar. Detta förbättrar integriteten men kan minska kompatibiliteten med vissa klienter.",
"save": "Spara profil",
"saved": "Profil sparad. Ändringar är nu synliga i fediverse.",
"public": {
"followPrompt": "Följ mig på fediverse",
"copyHandle": "Kopiera handtag",
"copied": "Kopierat!",
"pinnedPosts": "Fästa inlägg",
"recentPosts": "Senaste inlägg",
"joinedDate": "Gick med",
"posts": "Inlägg",
"followers": "Följare",
"following": "Följer",
"viewOnSite": "Visa på webbplatsen"
},
"remote": {
"follow": "Följ",
"unfollow": "Sluta följa",
"viewOn": "Visa på",
"postsTitle": "Inlägg",
"noPosts": "Inga inlägg från detta konto ännu.",
"followToSee": "Följ detta konto för att se deras inlägg i din tidslinje.",
"notFound": "Kunde inte hitta detta konto. Det kan ha raderats eller servern kan vara otillgänglig."
}
},
"migrate": {
"title": "Mastodon-migrering",
"intro": "Denna guide leder dig genom att flytta din Mastodon-identitet till din IndieWeb-webbplats. Slutför varje steg i ordning — dina befintliga följare kommer att meddelas och kan följa dig automatiskt.",
"step1Title": "Steg 1 — Länka ditt gamla konto",
"step1Desc": "Berätta för fediverse att ditt gamla Mastodon-konto och denna webbplats tillhör samma person. Detta sätter egenskapen <code>alsoKnownAs</code> på din ActivityPub-aktör, som Mastodon kontrollerar innan en flytt tillåts.",
"aliasLabel": "URL till gammalt Mastodon-konto",
"aliasHint": "Den fullständiga URL:en till din Mastodon-profil, t.ex. https://mstdn.social/users/rmdes",
"aliasSave": "Spara alias",
"aliasCurrent": "Aktuellt alias",
"aliasNone": "Inget alias konfigurerat ännu.",
"step2Title": "Steg 2 — Importera ditt sociala nätverk",
"step2Desc": "Ladda upp CSV-filerna från din Mastodon-dataexport för att ta med dina kontakter. Gå till din Mastodon-instans → Inställningar → Import och export → Dataexport för att ladda ner dem.",
"importLegend": "Vad som ska importeras",
"fileLabel": "CSV-fil",
"fileHint": "Välj en CSV-fil från din Mastodon-dataexport (t.ex. following_accounts.csv eller followers.csv)",
"importButton": "Importera",
"importFollowing": "Följlista",
"importFollowingHint": "Konton du följer — de visas omedelbart i din Följer-lista",
"importFollowers": "Följarlista",
"importFollowersHint": "Dina nuvarande följare — de registreras som väntande tills de följer dig igen efter flytten i steg 3",
"step3Title": "Steg 3 — Flytta ditt konto",
"step3Desc": "När du har sparat ditt alias och importerat dina data, gå till din Mastodon-instans → Inställningar → Konto → <strong>Flytta till ett annat konto</strong>. Ange ditt nya fediverse-handtag och bekräfta. Mastodon meddelar alla dina följare, och de vars servrar stöder det kommer automatiskt att följa dig här. Detta steg är oåterkalleligt — ditt gamla konto blir en omdirigering.",
"errorNoFile": "Välj en CSV-fil innan du importerar.",
"success": "Importerade %d följda, %d följare (%d misslyckades).",
"failedList": "Kunde inte lösa: %s",
"failedListSummary": "Misslyckade handtag",
"aliasSuccess": "Alias sparat — ditt aktörsdokument inkluderar nu detta konto som alsoKnownAs."
},
"refollow": {
"title": "Massomföljning",
"progress": "Omföljningsförlopp",
"remaining": "Återstående",
"awaitingAccept": "Väntar på godkännande",
"accepted": "Godkänd",
"failed": "Misslyckades",
"pause": "Pausa",
"resume": "Återuppta",
"status": {
"idle": "Inaktiv",
"running": "Körs",
"paused": "Pausad",
"completed": "Slutförd"
}
},
"moderation": {
"title": "Moderering",
"blockedTitle": "Blockerade konton",
"mutedActorsTitle": "Tystade konton",
"mutedKeywordsTitle": "Tystade nyckelord",
"noBlocked": "Inga blockerade konton.",
"noMutedActors": "Inga tystade konton.",
"noMutedKeywords": "Inga tystade nyckelord.",
"unblock": "Avblockera",
"unmute": "Sluta tysta",
"addKeywordTitle": "Lägg till tystat nyckelord",
"keywordPlaceholder": "Ange nyckelord eller fras…",
"addKeyword": "Lägg till",
"muteActor": "Tysta",
"blockActor": "Blockera",
"filterModeTitle": "Filterläge",
"filterModeHint": "Välj hur tystat innehåll hanteras i din tidslinje. Blockerade konton döljs alltid.",
"filterModeHide": "Dölj — ta bort från tidslinjen",
"filterModeWarn": "Varna — visa bakom innehållsvarning",
"cwMutedAccount": "Tystat konto",
"cwMutedKeyword": "Tystat nyckelord:",
"cwFiltered": "Filtrerat innehåll"
},
"compose": {
"title": "Skriv svar",
"placeholder": "Skriv ditt svar…",
"syndicateLabel": "Syndikera till",
"submitMicropub": "Publicera svar",
"cancel": "Avbryt",
"errorEmpty": "Svarsinnehållet kan inte vara tomt",
"visibilityLabel": "Synlighet",
"visibilityPublic": "Offentligt",
"visibilityUnlisted": "Olistad",
"visibilityFollowers": "Bara följare",
"cwLabel": "Innehållsvarning",
"cwPlaceholder": "Skriv din varning här…"
},
"notifications": {
"title": "Aviseringar",
"empty": "Inga aviseringar ännu. Interaktioner från andra fediverse-användare kommer att visas här.",
"liked": "gillade ditt inlägg",
"boostedPost": "boostade ditt inlägg",
"followedYou": "följde dig",
"repliedTo": "svarade på ditt inlägg",
"mentionedYou": "nämnde dig",
"markAllRead": "Markera alla som lästa",
"clearAll": "Rensa alla",
"clearConfirm": "Radera alla aviseringar? Detta kan inte ångras.",
"dismiss": "Avfärda",
"viewThread": "Visa tråd",
"tabs": {
"all": "Alla",
"replies": "Svar",
"likes": "Gillningar",
"boosts": "Boostar",
"follows": "Följningar",
"dms": "DM",
"reports": "Rapporter"
},
"emptyTab": "Inga %s-aviseringar ännu."
},
"messages": {
"title": "Meddelanden",
"empty": "Inga meddelanden ännu. Direktmeddelanden från andra fediverse-användare kommer att visas här.",
"allConversations": "Alla konversationer",
"compose": "Nytt meddelande",
"send": "Skicka meddelande",
"delete": "Radera",
"markAllRead": "Markera alla som lästa",
"clearAll": "Rensa alla",
"clearConfirm": "Radera alla meddelanden? Detta kan inte ångras.",
"recipientLabel": "Till",
"recipientPlaceholder": "@användare@instans.social",
"placeholder": "Skriv ditt meddelande...",
"sentTo": "Till",
"replyingTo": "Svarar",
"sentYouDM": "skickade dig ett direktmeddelande",
"viewMessage": "Visa meddelande",
"errorEmpty": "Meddelandeinnehållet kan inte vara tomt.",
"errorNoRecipient": "Ange en mottagare.",
"errorRecipientNotFound": "Kunde inte hitta den användaren. Prova ett fullständigt @användare@domän-handtag."
},
"reader": {
"title": "Läsare",
"tabs": {
"all": "Alla",
"notes": "Anteckningar",
"articles": "Artiklar",
"replies": "Svar",
"boosts": "Boostar",
"media": "Media"
},
"empty": "Din tidslinje är tom. Följ några konton för att se deras inlägg här.",
"boosted": "boostade",
"replyingTo": "Svarar",
"showContent": "Visa innehåll",
"hideContent": "Dölj innehåll",
"sensitiveContent": "Känsligt innehåll",
"videoNotSupported": "Din webbläsare stöder inte videoelementet.",
"audioNotSupported": "Din webbläsare stöder inte ljudelementet.",
"actions": {
"reply": "Svara",
"boost": "Boosta",
"unboost": "Ångra boost",
"like": "Gilla",
"unlike": "Ångra gillning",
"viewOriginal": "Visa original",
"liked": "Gillad",
"boosted": "Boostad",
"likeError": "Kunde inte gilla detta inlägg",
"boostError": "Kunde inte boosta detta inlägg"
},
"post": {
"title": "Inläggsdetalj",
"notFound": "Inlägg hittades inte eller är inte längre tillgängligt.",
"openExternal": "Öppna på ursprungsinstansen",
"parentPosts": "Tråd",
"replies": "Svar",
"back": "Tillbaka till tidslinjen",
"loadingThread": "Laddar tråd...",
"threadError": "Kunde inte ladda hela tråden"
},
"resolve": {
"placeholder": "Klistra in en fediverse-URL eller @användare@domän-handtag…",
"label": "Slå upp ett fediverse-inlägg eller -konto",
"button": "Slå upp",
"notFoundTitle": "Hittades inte",
"notFound": "Kunde inte hitta detta inlägg eller konto. URL:en kan vara ogiltig, servern kan vara otillgänglig eller innehållet kan ha raderats.",
"followersLabel": "följare"
},
"linkPreview": {
"label": "Länkförhandsvisning"
},
"explore": {
"title": "Utforska",
"description": "Bläddra i offentliga tidslinjer från fjärrinstanser som är kompatibla med Mastodon.",
"instancePlaceholder": "Ange ett instansvärdnamn, t.ex. mastodon.social",
"browse": "Bläddra",
"local": "Lokal",
"federated": "Federerad",
"loadError": "Kunde inte ladda tidslinjen från denna instans. Den kan vara otillgänglig eller stöder inte Mastodon-API:et.",
"timeout": "Förfrågan tog för lång tid. Instansen kan vara långsam eller otillgänglig.",
"noResults": "Inga inlägg hittades på denna instans offentliga tidslinje.",
"invalidInstance": "Ogiltigt instansvärdnamn. Ange ett giltigt domännamn.",
"mauLabel": "MAU",
"timelineSupported": "Offentlig tidslinje tillgänglig",
"timelineUnsupported": "Offentlig tidslinje inte tillgänglig",
"hashtagLabel": "Hashtag (valfritt)",
"hashtagPlaceholder": "t.ex. indieweb",
"hashtagHint": "Filtrera resultat efter en specifik hashtag",
"tabs": {
"label": "Utforska-flikar",
"search": "Sök",
"pinAsTab": "Fäst som flik",
"pinned": "Fästa",
"remove": "Ta bort flik",
"moveUp": "Flytta upp",
"moveDown": "Flytta ner",
"addHashtag": "Lägg till hashtag-flik",
"hashtagTabPlaceholder": "Ange hashtag",
"addTab": "Lägg till",
"retry": "Försök igen",
"noInstances": "Fäst några instanser först för att använda hashtag-flikar.",
"sources": "Söker #%s på %d instans",
"sources_plural": "Söker #%s på %d instanser",
"sourcesPartial": "%d av %d instanser svarade"
}
},
"tagTimeline": {
"postsTagged": "%d inlägg",
"postsTagged_plural": "%d inlägg",
"noPosts": "Inga inlägg med #%s hittades i din tidslinje.",
"followTag": "Följ hashtag",
"unfollowTag": "Sluta följa hashtag",
"following": "Följer"
},
"pagination": {
"newer": "← Nyare",
"older": "Äldre →",
"loadMore": "Ladda fler",
"loading": "Laddar…",
"noMore": "Du är ikapp."
}
},
"myProfile": {
"title": "Min profil",
"posts": "inlägg",
"editProfile": "Redigera profil",
"empty": "Inget här ännu.",
"tabs": {
"posts": "Inlägg",
"replies": "Svar",
"likes": "Gillningar",
"boosts": "Boostar"
}
},
"poll": {
"voters": "röstande",
"votes": "röster",
"closed": "Omröstning stängd",
"endsAt": "Slutar"
},
"federation": {
"deleteSuccess": "Raderingsaktivitet skickad till följare",
"deleteButton": "Radera från fediverse"
},
"federationMgmt": {
"title": "Federation",
"collections": "Samlingshälsa",
"quickActions": "Snabbåtgärder",
"broadcastActor": "Sänd aktörsuppdatering",
"debugDashboard": "Felsökningspanel",
"objectLookup": "Objektsökning",
"lookupPlaceholder": "URL eller @användare@domän-handtag…",
"lookup": "Slå upp",
"lookupLoading": "Löser…",
"postActions": "Inläggsfederation",
"viewJson": "JSON",
"rebroadcast": "Återsänd Create-aktivitet",
"rebroadcastShort": "Skicka igen",
"broadcastDelete": "Sänd Delete-aktivitet",
"deleteShort": "Radera",
"noPosts": "Inga inlägg hittades.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Senaste aktivitet",
"viewAllActivities": "Visa alla aktiviteter →"
},
"reports": {
"sentReport": "lämnade in en rapport",
"title": "Rapporter"
}
}
}

358
locales/zh-Hans-CN.json Normal file
View File

@@ -0,0 +1,358 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "关注者",
"following": "正在关注",
"activities": "活动日志",
"featured": "置顶帖子",
"featuredTags": "精选标签",
"recentActivity": "最近活动",
"noActivity": "暂无活动。当你的行为者完成联邦后,互动将显示在此处。",
"noFollowers": "暂无关注者。",
"noFollowing": "尚未关注任何人。",
"pendingFollows": "待处理",
"noPendingFollows": "没有待处理的关注请求。",
"approve": "批准",
"reject": "拒绝",
"followApproved": "关注请求已批准。",
"followRejected": "关注请求已拒绝。",
"followRequest": "请求关注你",
"followerCount": "%d 位关注者",
"followerCount_plural": "%d 位关注者",
"followingCount": "正在关注 %d 人",
"followedAt": "关注时间",
"source": "来源",
"sourceImport": "Mastodon 导入",
"sourceManual": "手动",
"sourceFederation": "联邦",
"sourceRefollowPending": "重新关注待处理",
"sourceRefollowFailed": "重新关注失败",
"direction": "方向",
"directionInbound": "已接收",
"directionOutbound": "已发送",
"profile": {
"title": "个人资料",
"intro": "编辑你的行为者在其他联邦宇宙用户面前的显示方式。更改会立即生效。",
"nameLabel": "显示名称",
"nameHint": "你在联邦宇宙个人资料上显示的名称",
"summaryLabel": "个人简介",
"summaryHint": "关于你自己的简短描述。允许使用 HTML。",
"urlLabel": "网站 URL",
"urlHint": "你的网站地址,在个人资料上显示为链接",
"iconLabel": "头像 URL",
"iconHint": "你的个人头像 URL方形建议至少 400x400 像素)",
"imageLabel": "横幅图片 URL",
"imageHint": "显示在个人资料顶部的横幅图片 URL",
"manualApprovalLabel": "手动批准关注者",
"manualApprovalHint": "启用后,关注请求需要你的批准才能生效",
"actorTypeLabel": "行为者类型",
"actorTypeHint": "你的账户在联邦宇宙中的显示方式。个人用 Person机器人或自动化账户用 Service团体或公司用 Organization。",
"linksLabel": "个人资料链接",
"linksHint": "显示在你联邦宇宙个人资料上的链接。添加你的网站、社交账户或其他 URL。使用 rel=\"me\" 回链的页面将在 Mastodon 上显示为已验证。",
"linkNameLabel": "标签",
"linkValueLabel": "URL",
"addLink": "添加链接",
"removeLink": "移除",
"authorizedFetchLabel": "要求授权获取(安全模式)",
"authorizedFetchHint": "启用后,只有具有有效 HTTP 签名的服务器才能获取你的行为者和集合。这可以提高隐私性,但可能降低与某些客户端的兼容性。",
"save": "保存个人资料",
"saved": "个人资料已保存。更改现在在联邦宇宙中可见。",
"public": {
"followPrompt": "在联邦宇宙关注我",
"copyHandle": "复制标识",
"copied": "已复制!",
"pinnedPosts": "置顶帖子",
"recentPosts": "最近帖子",
"joinedDate": "加入时间",
"posts": "帖子",
"followers": "关注者",
"following": "正在关注",
"viewOnSite": "在网站上查看"
},
"remote": {
"follow": "关注",
"unfollow": "取消关注",
"viewOn": "查看于",
"postsTitle": "帖子",
"noPosts": "此账户暂无帖子。",
"followToSee": "关注此账户以在你的时间线中查看他们的帖子。",
"notFound": "无法找到此账户。它可能已被删除或服务器不可用。"
}
},
"migrate": {
"title": "Mastodon 迁移",
"intro": "本指南将引导你将 Mastodon 身份迁移到你的 IndieWeb 网站。按顺序完成每个步骤——你现有的关注者将收到通知,并可以自动重新关注你。",
"step1Title": "步骤 1 — 关联你的旧账户",
"step1Desc": "告诉联邦宇宙你的旧 Mastodon 账户和本网站属于同一个人。这会在你的 ActivityPub 行为者上设置 <code>alsoKnownAs</code> 属性Mastodon 在允许迁移前会检查此属性。",
"aliasLabel": "旧 Mastodon 账户 URL",
"aliasHint": "你的 Mastodon 个人资料的完整 URL例如 https://mstdn.social/users/rmdes",
"aliasSave": "保存别名",
"aliasCurrent": "当前别名",
"aliasNone": "尚未配置别名。",
"step2Title": "步骤 2 — 导入你的社交网络",
"step2Desc": "上传你 Mastodon 数据导出中的 CSV 文件以导入你的社交关系。前往你的 Mastodon 实例 → 首选项 → 导入和导出 → 数据导出来下载它们。",
"importLegend": "要导入的内容",
"fileLabel": "CSV 文件",
"fileHint": "选择你 Mastodon 数据导出中的 CSV 文件(例如 following_accounts.csv 或 followers.csv",
"importButton": "导入",
"importFollowing": "关注列表",
"importFollowingHint": "你关注的账户——它们将立即出现在你的关注列表中",
"importFollowers": "关注者列表",
"importFollowersHint": "你当前的关注者——在步骤 3 迁移后他们重新关注你之前,将被记录为待处理状态",
"step3Title": "步骤 3 — 迁移你的账户",
"step3Desc": "保存别名并导入数据后,前往你的 Mastodon 实例 → 首选项 → 账户 → <strong>迁移到另一个账户</strong>。输入你的新联邦宇宙标识并确认。Mastodon 将通知你所有的关注者,支持此功能的服务器上的关注者将自动在此处重新关注你。此步骤不可逆——你的旧账户将变为重定向。",
"errorNoFile": "请在导入前选择一个 CSV 文件。",
"success": "已导入 %d 个关注、%d 个关注者(%d 个失败)。",
"failedList": "无法解析:%s",
"failedListSummary": "失败的标识",
"aliasSuccess": "别名已保存——你的行为者文档现在包含此账户作为 alsoKnownAs。"
},
"refollow": {
"title": "批量重新关注",
"progress": "重新关注进度",
"remaining": "剩余",
"awaitingAccept": "等待接受",
"accepted": "已接受",
"failed": "失败",
"pause": "暂停",
"resume": "继续",
"status": {
"idle": "空闲",
"running": "运行中",
"paused": "已暂停",
"completed": "已完成"
}
},
"moderation": {
"title": "内容审核",
"blockedTitle": "已屏蔽账户",
"mutedActorsTitle": "已静音账户",
"mutedKeywordsTitle": "已静音关键词",
"noBlocked": "没有已屏蔽的账户。",
"noMutedActors": "没有已静音的账户。",
"noMutedKeywords": "没有已静音的关键词。",
"unblock": "取消屏蔽",
"unmute": "取消静音",
"addKeywordTitle": "添加静音关键词",
"keywordPlaceholder": "输入关键词或短语…",
"addKeyword": "添加",
"muteActor": "静音",
"blockActor": "屏蔽",
"filterModeTitle": "过滤模式",
"filterModeHint": "选择如何处理时间线中的静音内容。已屏蔽的账户始终隐藏。",
"filterModeHide": "隐藏——从时间线中移除",
"filterModeWarn": "警告——在内容警告后显示",
"cwMutedAccount": "已静音账户",
"cwMutedKeyword": "已静音关键词:",
"cwFiltered": "已过滤内容"
},
"compose": {
"title": "撰写回复",
"placeholder": "写下你的回复…",
"syndicateLabel": "联合发布到",
"submitMicropub": "发布回复",
"cancel": "取消",
"errorEmpty": "回复内容不能为空",
"visibilityLabel": "可见性",
"visibilityPublic": "公开",
"visibilityUnlisted": "不列出",
"visibilityFollowers": "仅关注者",
"cwLabel": "内容警告",
"cwPlaceholder": "在此写下你的警告…"
},
"notifications": {
"title": "通知",
"empty": "暂无通知。来自其他联邦宇宙用户的互动将显示在此处。",
"liked": "赞了你的帖子",
"boostedPost": "转发了你的帖子",
"followedYou": "关注了你",
"repliedTo": "回复了你的帖子",
"mentionedYou": "提到了你",
"markAllRead": "全部标为已读",
"clearAll": "全部清除",
"clearConfirm": "删除所有通知?此操作无法撤销。",
"dismiss": "忽略",
"viewThread": "查看主题",
"tabs": {
"all": "全部",
"replies": "回复",
"likes": "赞",
"boosts": "转发",
"follows": "关注",
"dms": "私信",
"reports": "举报"
},
"emptyTab": "暂无%s通知。"
},
"messages": {
"title": "消息",
"empty": "暂无消息。来自其他联邦宇宙用户的私信将显示在此处。",
"allConversations": "所有对话",
"compose": "新消息",
"send": "发送消息",
"delete": "删除",
"markAllRead": "全部标为已读",
"clearAll": "全部清除",
"clearConfirm": "删除所有消息?此操作无法撤销。",
"recipientLabel": "收件人",
"recipientPlaceholder": "@用户@实例.social",
"placeholder": "写下你的消息...",
"sentTo": "收件人",
"replyingTo": "回复",
"sentYouDM": "向你发送了一条私信",
"viewMessage": "查看消息",
"errorEmpty": "消息内容不能为空。",
"errorNoRecipient": "请输入收件人。",
"errorRecipientNotFound": "无法找到该用户。请尝试完整的 @用户@域名 标识。"
},
"reader": {
"title": "阅读器",
"tabs": {
"all": "全部",
"notes": "短文",
"articles": "文章",
"replies": "回复",
"boosts": "转发",
"media": "媒体"
},
"empty": "你的时间线为空。关注一些账户以在此查看他们的帖子。",
"boosted": "转发了",
"replyingTo": "回复",
"showContent": "显示内容",
"hideContent": "隐藏内容",
"sensitiveContent": "敏感内容",
"videoNotSupported": "你的浏览器不支持视频元素。",
"audioNotSupported": "你的浏览器不支持音频元素。",
"actions": {
"reply": "回复",
"boost": "转发",
"unboost": "取消转发",
"like": "赞",
"unlike": "取消赞",
"viewOriginal": "查看原文",
"liked": "已赞",
"boosted": "已转发",
"likeError": "无法赞此帖子",
"boostError": "无法转发此帖子"
},
"post": {
"title": "帖子详情",
"notFound": "帖子未找到或不再可用。",
"openExternal": "在原始实例上打开",
"parentPosts": "主题",
"replies": "回复",
"back": "返回时间线",
"loadingThread": "正在加载主题...",
"threadError": "无法加载完整主题"
},
"resolve": {
"placeholder": "粘贴一个联邦宇宙 URL 或 @用户@域名 标识…",
"label": "查找联邦宇宙帖子或账户",
"button": "查找",
"notFoundTitle": "未找到",
"notFound": "无法找到此帖子或账户。URL 可能无效,服务器可能不可用,或内容可能已被删除。",
"followersLabel": "关注者"
},
"linkPreview": {
"label": "链接预览"
},
"explore": {
"title": "探索",
"description": "浏览来自兼容 Mastodon 的远程实例的公共时间线。",
"instancePlaceholder": "输入实例主机名,例如 mastodon.social",
"browse": "浏览",
"local": "本地",
"federated": "联邦",
"loadError": "无法加载此实例的时间线。它可能不可用或不支持 Mastodon API。",
"timeout": "请求超时。该实例可能较慢或不可用。",
"noResults": "此实例的公共时间线上未找到帖子。",
"invalidInstance": "无效的实例主机名。请输入有效的域名。",
"mauLabel": "MAU",
"timelineSupported": "公共时间线可用",
"timelineUnsupported": "公共时间线不可用",
"hashtagLabel": "标签(可选)",
"hashtagPlaceholder": "例如 indieweb",
"hashtagHint": "按特定标签筛选结果",
"tabs": {
"label": "探索选项卡",
"search": "搜索",
"pinAsTab": "固定为选项卡",
"pinned": "已固定",
"remove": "移除选项卡",
"moveUp": "上移",
"moveDown": "下移",
"addHashtag": "添加标签选项卡",
"hashtagTabPlaceholder": "输入标签",
"addTab": "添加",
"retry": "重试",
"noInstances": "先固定一些实例以使用标签选项卡。",
"sources": "正在 %d 个实例上搜索 #%s",
"sources_plural": "正在 %d 个实例上搜索 #%s",
"sourcesPartial": "%d 个实例中有 %d 个已响应"
}
},
"tagTimeline": {
"postsTagged": "%d 条帖子",
"postsTagged_plural": "%d 条帖子",
"noPosts": "在你的时间线中未找到带有 #%s 的帖子。",
"followTag": "关注标签",
"unfollowTag": "取消关注标签",
"following": "正在关注"
},
"pagination": {
"newer": "← 更新的",
"older": "更早的 →",
"loadMore": "加载更多",
"loading": "加载中…",
"noMore": "你已经看完了所有内容。"
}
},
"myProfile": {
"title": "我的个人资料",
"posts": "帖子",
"editProfile": "编辑个人资料",
"empty": "这里还没有内容。",
"tabs": {
"posts": "帖子",
"replies": "回复",
"likes": "赞",
"boosts": "转发"
}
},
"poll": {
"voters": "投票者",
"votes": "票",
"closed": "投票已关闭",
"endsAt": "结束时间"
},
"federation": {
"deleteSuccess": "删除活动已发送给关注者",
"deleteButton": "从联邦宇宙删除"
},
"federationMgmt": {
"title": "联邦",
"collections": "集合状态",
"quickActions": "快捷操作",
"broadcastActor": "广播行为者更新",
"debugDashboard": "调试面板",
"objectLookup": "对象查找",
"lookupPlaceholder": "URL 或 @用户@域名 标识…",
"lookup": "查找",
"lookupLoading": "解析中…",
"postActions": "帖子联邦",
"viewJson": "JSON",
"rebroadcast": "重新广播 Create 活动",
"rebroadcastShort": "重新发送",
"broadcastDelete": "广播 Delete 活动",
"deleteShort": "删除",
"noPosts": "未找到帖子。",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "最近活动",
"viewAllActivities": "查看所有活动 →"
},
"reports": {
"sentReport": "提交了举报",
"title": "举报"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.15.4",
"version": "3.6.8",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -47,6 +47,7 @@
"unfurl.js": "^6.4.0"
},
"peerDependencies": {
"@indiekit/endpoint-micropub": "^1.0.0-beta.25",
"@indiekit/error": "^1.0.0-beta.25",
"@indiekit/frontend": "^1.0.0-beta.25"
},