fix(news): preserve recoverable reads and verify publication against real services
This commit is contained in:
1 parent
8abfe352ef
commit
f7b9b55700
33 files changed
+1377
-220
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { format } from "mysql2";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -9,6 +10,7 @@ const state = vi.hoisted(() => ({
|
||||
status: string;
|
||||
publishAt: Date;
|
||||
}>,
|
||||
queries: [] as Array<{ sql: string; params: unknown[] }>,
|
||||
operations: [] as unknown[][],
|
||||
effects: [] as unknown[][],
|
||||
failOutbox: false,
|
||||
@@ -19,15 +21,20 @@ vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("./news-cache", () => ({ invalidateNewsCache: state.refresh }));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const dialect = new MySqlDialect();
|
||||
const timestamp = (value: unknown) =>
|
||||
value instanceof Date
|
||||
? value
|
||||
: new Date(`${String(value).replace(" ", "T")}Z`);
|
||||
const tx = {
|
||||
execute: async (query: SQL) => {
|
||||
const { sql, params } = dialect.sqlToQuery(query);
|
||||
state.queries.push({ sql, params });
|
||||
if (sql.startsWith("SELECT"))
|
||||
return [
|
||||
state.articles
|
||||
.filter(
|
||||
(a) =>
|
||||
a.status === "scheduled" && a.publishAt <= (params[0] as Date),
|
||||
a.status === "scheduled" && a.publishAt <= timestamp(params[0]),
|
||||
)
|
||||
.slice(0, 100),
|
||||
];
|
||||
@@ -37,7 +44,7 @@ vi.mock("@/lib/db", () => {
|
||||
state.loseRace ||
|
||||
!article ||
|
||||
article.status !== "scheduled" ||
|
||||
article.publishAt > (params[3] as Date)
|
||||
article.publishAt > timestamp(params[3])
|
||||
)
|
||||
return [{ affectedRows: 0 }];
|
||||
article.status = "published";
|
||||
@@ -83,6 +90,7 @@ beforeEach(() => {
|
||||
publishAt: new Date("2030-01-01T00:00:00Z"),
|
||||
},
|
||||
];
|
||||
state.queries = [];
|
||||
state.operations = [];
|
||||
state.effects = [];
|
||||
state.failOutbox = false;
|
||||
@@ -128,3 +136,25 @@ it("reports committed publication even when immediate cache refresh fails", asyn
|
||||
expect(state.effects).toHaveLength(1);
|
||||
expect(state.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the same UTC cutoff and publication timestamps regardless of the MySQL client timezone", async () => {
|
||||
const instant = new Date("2030-06-01T08:09:10.123Z");
|
||||
expect(await publishDueArticles(instant)).toBe(1);
|
||||
const statements = state.queries.filter(
|
||||
({ sql }) =>
|
||||
sql.startsWith("SELECT") || sql.startsWith("UPDATE website_articles"),
|
||||
);
|
||||
expect(statements).toHaveLength(2);
|
||||
for (const statement of statements) {
|
||||
expect(format(statement.sql, statement.params, false, "+02:00")).toBe(
|
||||
format(statement.sql, statement.params, false, "-07:00"),
|
||||
);
|
||||
}
|
||||
expect(statements[0].params).toEqual(["2030-06-01 08:09:10.123"]);
|
||||
expect(statements[1].params).toEqual([
|
||||
"2030-06-01 08:09:10.123",
|
||||
"2030-06-01 08:09:10.123",
|
||||
"1",
|
||||
"2030-06-01 08:09:10.123",
|
||||
]);
|
||||
});
|
||||
@@ -9,9 +9,11 @@ import { invalidateNewsCache } from "./news-cache";
|
||||
|
||||
/** Publish a bounded batch and persist its refresh work before committing. */
|
||||
export async function publishDueArticles(now = new Date()): Promise<number> {
|
||||
// Match Drizzle's TIMESTAMP writes; raw Date parameters use the host timezone.
|
||||
const utcNow = now.toISOString().slice(0, -1).replace("T", " ");
|
||||
const published = await db.transaction(async (tx) => {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${now} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`,
|
||||
sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${utcNow} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`,
|
||||
);
|
||||
let published = 0;
|
||||
for (const article of rows as unknown as Array<{
|
||||
@@ -20,7 +22,7 @@ export async function publishDueArticles(now = new Date()): Promise<number> {
|
||||
publishAt: Date | string;
|
||||
}>) {
|
||||
const [result] = await tx.execute(
|
||||
sql`UPDATE website_articles SET status='published',published_at=${now},updated_at=${now} WHERE id=${article.id} AND status='scheduled' AND publish_at<=${now}`,
|
||||
sql`UPDATE website_articles SET status='published',published_at=${utcNow},updated_at=${utcNow} WHERE id=${article.id} AND status='scheduled' AND publish_at<=${utcNow}`,
|
||||
);
|
||||
if ((result as unknown as { affectedRows: number }).affectedRows !== 1)
|
||||
continue;
|
||||
|
||||
@@ -3919,7 +3919,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Снимки",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotografie",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotos",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotos",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Φωτογραφίες",
|
||||
|
||||
@@ -339,7 +339,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotos",
|
||||
|
||||
@@ -3919,7 +3919,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotografije",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fényképek",
|
||||
|
||||
@@ -339,7 +339,15 @@
|
||||
"invalid": "Commento non valido.",
|
||||
"notFound": "Articolo non trovato.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"loadError": "Questo articolo è temporaneamente non disponibile.",
|
||||
"retry": "Riprova",
|
||||
"commentsUnavailable": "I commenti sono temporaneamente non disponibili.",
|
||||
"reactionsUnavailable": "Le reazioni sono temporaneamente non disponibili.",
|
||||
"commentsPagination": "Pagine dei commenti",
|
||||
"previous": "Precedente",
|
||||
"next": "Successiva",
|
||||
"commentsPageSummary": "Pagina {page} di {pages} · {total} commenti"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Foto",
|
||||
|
||||
@@ -3919,7 +3919,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
|
||||
@@ -339,7 +339,15 @@
|
||||
"invalid": "Ongeldige reactie.",
|
||||
"notFound": "Dat artikel is niet gevonden.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"loadError": "Dit artikel is tijdelijk niet beschikbaar.",
|
||||
"retry": "Opnieuw proberen",
|
||||
"commentsUnavailable": "Reacties zijn tijdelijk niet beschikbaar.",
|
||||
"reactionsUnavailable": "Waarderingen zijn tijdelijk niet beschikbaar.",
|
||||
"commentsPagination": "Reactiepagina’s",
|
||||
"previous": "Vorige",
|
||||
"next": "Volgende",
|
||||
"commentsPageSummary": "Pagina {page} van {pages} · {total} reacties"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Foto's",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Bilder",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Zdjęcia",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotos",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotografii",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Фото",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotografie",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Фотографије",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Foton",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Fotoğraflar",
|
||||
|
||||
@@ -297,7 +297,15 @@
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"loadError": "This article is temporarily unavailable.",
|
||||
"retry": "Try again",
|
||||
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||
"commentsPagination": "Comment pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Фотографії",
|
||||
|
||||
Reference in new issue
Block a user