[core] refactor: extract and centralize HTTP fetcher (#4016)

## Summary

PR [#3976](https://github.com/MagicMirrorOrg/MagicMirror/pull/3976)
introduced smart HTTP error handling for the Calendar module. This PR
extracts that HTTP logic into a central `HTTPFetcher` class.

Calendar is the first module to use it. Follow-up PRs would migrate
Newsfeed and maybe even Weather.

**Before this change:**

-  Each module had to implemented its own `fetch()` calls
-  No centralized retry logic or backoff strategies
-  No timeout handling for hanging requests
-  Error detection relied on fragile string parsing

**What this PR adds:**

-  Unified HTTPFetcher class with intelligent retry strategies
-  Modern AbortController with configurable timeout (default 30s)
-  Proper undici Agent for self-signed certificates
-  Structured error objects with translation keys
-  Calendar module migrated as first consumer
-  Comprehensive unit tests with msw (Mock Service Worker)

## Architecture

**Before - Decentralized HTTP handling:**

```
Calendar Module          Newsfeed Module         Weather Module
┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│ fetch() own │         │ fetch() own │         │ fetch() own │
│ retry logic │         │ basic error │         │ no retry    │
│ error parse │         │   handling  │         │ client-side │
└─────────────┘         └─────────────┘         └─────────────┘
      │                       │                       │
      └───────────────────────┴───────────────────────┘
                              ▼
                        External APIs
```

**After - Centralized with HTTPFetcher:**

```
┌─────────────────────────────────────────────────────┐
│                  HTTPFetcher                        │
│  • Unified retry strategies (401/403, 429, 5xx)     │
│  • AbortController timeout (30s)                    │
│  • Structured errors with translation keys          │
│  • undici Agent for self-signed certs               │
└────────────┬──────────────┬──────────────┬──────────┘
             │              │              │
     ┌───────▼───────┐ ┌────▼─────┐ ┌──────▼──────┐
     │   Calendar    │ │ Newsfeed │ │   Weather   │
     │    This PR  │ │  future  │ │   future    │
     └───────────────┘ └──────────┘ └─────────────┘
             │              │              │
             └──────────────┴──────────────┘
                          ▼
                   External APIs
```
## Complexity Considerations

**Does HTTPFetcher add complexity?**

Even if it may look more complex, it actually **reduces overall
complexity**:

- **Calendar already has this logic** (PR #3976) - we're extracting, not
adding
- **Alternative is worse:** Each module implementing own logic = 3× the
code
- **Better testability:** 443 lines of tests once vs. duplicating tests
for each module
- **Standards-based:** Retry-After is RFC 7231, not custom logic

## Future Benefits

**Weather migration (future PR):**

Moving Weather from client-side to server-side will enable:
- **Same robust error handling** - Weather gets 429 rate-limiting, 5xx
backoff for free
- **Simpler architecture** - No proxy layer needed

Moving the weather modules from client-side to server-side will be a big
undertaking, but I think it's a good strategy. Even if we only move the
calendar and newsfeed to the new HTTP fetcher and leave the weather as
it is, this PR still makes sense, I think.

## Breaking Changes

**None**

----

I am eager to hear your opinion on this 🙂
This commit is contained in:
Kristjan ESPERANTO
2026-01-22 19:24:37 +01:00
committed by GitHub
parent 23f0290139
commit 34913bfb9f
52 changed files with 1464 additions and 156 deletions

300
js/http_fetcher.js Normal file
View File

@@ -0,0 +1,300 @@
const { EventEmitter } = require("node:events");
const { Agent } = require("undici");
const Log = require("logger");
const { getUserAgent } = require("#server_functions");
const FIFTEEN_MINUTES = 15 * 60 * 1000;
const THIRTY_MINUTES = 30 * 60 * 1000;
const MAX_SERVER_BACKOFF = 3;
const DEFAULT_TIMEOUT = 30000; // 30 seconds
/**
* Maps errorType to MagicMirror translation keys.
* This allows HTTPFetcher to provide ready-to-use translation keys,
* eliminating the need to call NodeHelper.checkFetchError().
*/
const ERROR_TYPE_TO_TRANSLATION = {
AUTH_FAILURE: "MODULE_ERROR_UNAUTHORIZED",
RATE_LIMITED: "MODULE_ERROR_RATE_LIMITED",
SERVER_ERROR: "MODULE_ERROR_SERVER_ERROR",
CLIENT_ERROR: "MODULE_ERROR_CLIENT_ERROR",
NETWORK_ERROR: "MODULE_ERROR_NO_CONNECTION",
UNKNOWN_ERROR: "MODULE_ERROR_UNSPECIFIED"
};
/**
* HTTPFetcher - Centralized HTTP fetching with intelligent error handling
*
* Features:
* - Automatic retry strategies based on HTTP status codes
* - Exponential backoff for server errors
* - Retry-After header parsing for rate limiting
* - Authentication support (Basic, Bearer)
* - Self-signed certificate support
* @augments EventEmitter
* @fires HTTPFetcher#response - When fetch succeeds with ok response
* @fires HTTPFetcher#error - When fetch fails or returns non-ok response
* @example
* const fetcher = new HTTPFetcher(url, { reloadInterval: 60000 });
* fetcher.on('response', (response) => { ... });
* fetcher.on('error', (errorInfo) => { ... });
* fetcher.startPeriodicFetch();
*/
class HTTPFetcher extends EventEmitter {
/**
* Creates a new HTTPFetcher instance
* @param {string} url - The URL to fetch
* @param {object} options - Configuration options
* @param {number} [options.reloadInterval] - Time in ms between fetches (default: 5 min)
* @param {object} [options.auth] - Authentication options
* @param {string} [options.auth.method] - 'basic' or 'bearer'
* @param {string} [options.auth.user] - Username for basic auth
* @param {string} [options.auth.pass] - Password or token
* @param {boolean} [options.selfSignedCert] - Accept self-signed certificates
* @param {object} [options.headers] - Additional headers to send
* @param {number} [options.maxRetries] - Max retries for 5xx errors (default: 3)
* @param {number} [options.timeout] - Request timeout in ms (default: 30000)
*/
constructor (url, options = {}) {
super();
this.url = url;
this.reloadInterval = options.reloadInterval || 5 * 60 * 1000;
this.auth = options.auth || null;
this.selfSignedCert = options.selfSignedCert || false;
this.customHeaders = options.headers || {};
this.maxRetries = options.maxRetries || MAX_SERVER_BACKOFF;
this.timeout = options.timeout || DEFAULT_TIMEOUT;
this.reloadTimer = null;
this.serverErrorCount = 0;
}
/**
* Clears any pending reload timer
*/
clearTimer () {
if (this.reloadTimer) {
clearTimeout(this.reloadTimer);
this.reloadTimer = null;
}
}
/**
* Schedules the next fetch.
* If no delay is provided, uses reloadInterval.
* If delay is provided but very short (< 1 second), clamps to reloadInterval
* to prevent hammering servers.
* @param {number} [delay] - Delay in milliseconds
*/
scheduleNextFetch (delay) {
let nextDelay = delay ?? this.reloadInterval;
// Only clamp if delay is unreasonably short (< 1 second)
// This allows respecting Retry-After headers while preventing abuse
if (nextDelay < 1000) {
nextDelay = this.reloadInterval;
}
// Don't schedule in test mode
if (process.env.mmTestMode === "true") {
return;
}
this.reloadTimer = setTimeout(() => this.fetch(), nextDelay);
}
/**
* Starts periodic fetching
*/
startPeriodicFetch () {
this.fetch();
}
/**
* Builds the options object for fetch
* @returns {object} Options object containing headers (and dispatcher if needed)
*/
getRequestOptions () {
const headers = {
"User-Agent": getUserAgent(),
...this.customHeaders
};
const options = { headers };
if (this.selfSignedCert) {
options.dispatcher = new Agent({
connect: {
rejectUnauthorized: false
}
});
}
if (this.auth) {
if (this.auth.method === "bearer") {
headers.Authorization = `Bearer ${this.auth.pass}`;
} else {
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
}
}
return options;
}
/**
* Parses the Retry-After header value
* @param {string} retryAfter - The Retry-After header value
* @returns {number|null} Milliseconds to wait or null if parsing failed
*/
#parseRetryAfter (retryAfter) {
// Try parsing as seconds
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds) && seconds >= 0) {
return seconds * 1000;
}
// Try parsing as HTTP-date
const retryDate = Date.parse(retryAfter);
if (!Number.isNaN(retryDate)) {
return Math.max(0, retryDate - Date.now());
}
return null;
}
/**
* Determines the retry delay for a non-ok response
* @param {Response} response - The fetch Response object
* @returns {{delay: number, errorInfo: object}} Computed retry delay and error info
*/
#getDelayForResponse (response) {
const { status } = response;
let delay = this.reloadInterval;
let message = "";
let errorType = "UNKNOWN_ERROR";
if (status === 401 || status === 403) {
errorType = "AUTH_FAILURE";
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
message = `Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`;
Log.error(`${this.url} - ${message}`);
} else if (status === 429) {
errorType = "RATE_LIMITED";
const retryAfter = response.headers.get("retry-after");
const parsed = retryAfter ? this.#parseRetryAfter(retryAfter) : null;
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
message = `Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`;
Log.warn(`${this.url} - ${message}`);
} else if (status >= 500) {
errorType = "SERVER_ERROR";
this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries);
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`;
Log.error(`${this.url} - ${message}`);
} else if (status >= 400) {
errorType = "CLIENT_ERROR";
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
message = `Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`;
Log.error(`${this.url} - ${message}`);
} else {
message = `Unexpected HTTP status ${status}.`;
Log.error(`${this.url} - ${message}`);
}
return {
delay,
errorInfo: this.#createErrorInfo(message, status, errorType, delay)
};
}
/**
* Creates a standardized error info object
* @param {string} message - Error message
* @param {number|null} status - HTTP status code or null for network errors
* @param {string} errorType - Error type: AUTH_FAILURE, RATE_LIMITED, SERVER_ERROR, CLIENT_ERROR, NETWORK_ERROR
* @param {number} retryAfter - Delay until next retry in ms
* @param {Error} [originalError] - The original error if any
* @returns {object} Error info object with translationKey for direct use
*/
#createErrorInfo (message, status, errorType, retryAfter, originalError = null) {
return {
message,
status,
errorType,
translationKey: ERROR_TYPE_TO_TRANSLATION[errorType] || "MODULE_ERROR_UNSPECIFIED",
retryAfter,
retryCount: this.serverErrorCount,
url: this.url,
originalError
};
}
/**
* Performs the HTTP fetch and emits appropriate events
* @fires HTTPFetcher#response
* @fires HTTPFetcher#error
*/
async fetch () {
this.clearTimer();
let nextDelay = this.reloadInterval;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(this.url, {
...this.getRequestOptions(),
signal: controller.signal
});
if (!response.ok) {
const { delay, errorInfo } = this.#getDelayForResponse(response);
nextDelay = delay;
this.emit("error", errorInfo);
} else {
// Reset server error count on success
this.serverErrorCount = 0;
/**
* Response event - fired when fetch succeeds
* @event HTTPFetcher#response
* @type {Response}
*/
this.emit("response", response);
}
} catch (error) {
const isTimeout = error.name === "AbortError";
const message = isTimeout ? `Request timeout after ${this.timeout}ms` : `Network error: ${error.message}`;
Log.error(`${this.url} - ${message}`);
const errorInfo = this.#createErrorInfo(
message,
null,
"NETWORK_ERROR",
this.reloadInterval,
error
);
/**
* Error event - fired when fetch fails
* @event HTTPFetcher#error
* @type {object}
* @property {string} message - Error description
* @property {number|null} statusCode - HTTP status or null for network errors
* @property {number} retryDelay - Ms until next retry
* @property {number} retryCount - Number of consecutive server errors
* @property {string} url - The URL that was fetched
* @property {Error|null} originalError - The original error
*/
this.emit("error", errorInfo);
} finally {
clearTimeout(timeoutId);
}
this.scheduleNextFetch(nextDelay);
}
}
module.exports = HTTPFetcher;

View File

@@ -2,15 +2,11 @@ const ical = require("node-ical");
const Log = require("logger");
const { Agent } = require("undici");
const CalendarFetcherUtils = require("./calendarfetcherutils");
const { getUserAgent } = require("#server_functions");
const FIFTEEN_MINUTES = 15 * 60 * 1000;
const THIRTY_MINUTES = 30 * 60 * 1000;
const MAX_SERVER_BACKOFF = 3;
const HTTPFetcher = require("#http_fetcher");
/**
* CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling
* CalendarFetcher - Fetches and parses iCal calendar data
* Uses HTTPFetcher for HTTP handling with intelligent error handling
* @class
*/
class CalendarFetcher {
@@ -28,162 +24,68 @@ class CalendarFetcher {
*/
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
this.url = url;
this.reloadInterval = reloadInterval;
this.excludedEvents = excludedEvents;
this.maximumEntries = maximumEntries;
this.maximumNumberOfDays = maximumNumberOfDays;
this.auth = auth;
this.includePastEvents = includePastEvents;
this.selfSignedCert = selfSignedCert;
this.events = [];
this.reloadTimer = null;
this.serverErrorCount = 0;
this.lastFetch = null;
this.fetchFailedCallback = () => {};
this.eventsReceivedCallback = () => {};
// Use HTTPFetcher for HTTP handling (Composition)
this.httpFetcher = new HTTPFetcher(url, {
reloadInterval,
auth,
selfSignedCert
});
// Wire up HTTPFetcher events
this.httpFetcher.on("response", (response) => this.#handleResponse(response));
this.httpFetcher.on("error", (errorInfo) => this.fetchFailedCallback(this, errorInfo));
}
/**
* Clears any pending reload timer
* Handles successful HTTP response
* @param {Response} response - The fetch Response object
*/
clearReloadTimer () {
if (this.reloadTimer) {
clearTimeout(this.reloadTimer);
this.reloadTimer = null;
}
}
async #handleResponse (response) {
try {
const responseData = await response.text();
const parsed = ical.parseICS(responseData);
/**
* Schedules the next fetch respecting MagicMirror test mode
* @param {number} delay - Delay in milliseconds
*/
scheduleNextFetch (delay) {
const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval);
if (process.env.mmTestMode === "true") {
return;
}
this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay);
}
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
/**
* Builds the options object for fetch
* @returns {object} Options object containing headers (and agent if needed)
*/
getRequestOptions () {
const headers = { "User-Agent": getUserAgent() };
const options = { headers };
this.events = CalendarFetcherUtils.filterEvents(parsed, {
excludedEvents: this.excludedEvents,
includePastEvents: this.includePastEvents,
maximumEntries: this.maximumEntries,
maximumNumberOfDays: this.maximumNumberOfDays
});
if (this.selfSignedCert) {
options.dispatcher = new Agent({
connect: {
rejectUnauthorized: false
}
this.lastFetch = Date.now();
this.broadcastEvents();
} catch (error) {
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
this.fetchFailedCallback(this, {
message: `iCal parsing failed: ${error.message}`,
status: null,
errorType: "PARSE_ERROR",
translationKey: "MODULE_ERROR_UNSPECIFIED",
retryAfter: this.httpFetcher.reloadInterval,
retryCount: 0,
url: this.url,
originalError: error
});
}
if (this.auth) {
if (this.auth.method === "bearer") {
headers.Authorization = `Bearer ${this.auth.pass}`;
} else {
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
}
}
return options;
}
/**
* Parses the Retry-After header value
* @param {string} retryAfter - The Retry-After header value
* @returns {number|null} Milliseconds to wait or null if parsing failed
* Starts fetching calendar data
*/
parseRetryAfter (retryAfter) {
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds) && seconds >= 0) {
return seconds * 1000;
}
const retryDate = Date.parse(retryAfter);
if (!Number.isNaN(retryDate)) {
return Math.max(0, retryDate - Date.now());
}
return null;
}
/**
* Determines the retry delay for a non-ok response
* @param {Response} response - The fetch Response object
* @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay
*/
getDelayForResponse (response) {
const { status, statusText = "" } = response;
let delay = this.reloadInterval;
if (status === 401 || status === 403) {
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`);
} else if (status === 429) {
const retryAfter = response.headers.get("retry-after");
const parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null;
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`);
} else if (status >= 500) {
this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF);
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`);
} else if (status >= 400) {
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`);
} else {
Log.error(`${this.url} - Unexpected HTTP status ${status}.`);
}
return {
delay,
error: new Error(`HTTP ${status} ${statusText}`.trim())
};
}
/**
* Fetches and processes calendar data
*/
async fetchCalendar () {
this.clearReloadTimer();
let nextDelay = this.reloadInterval;
try {
const response = await fetch(this.url, this.getRequestOptions());
if (!response.ok) {
const { delay, error } = this.getDelayForResponse(response);
nextDelay = delay;
this.fetchFailedCallback(this, error);
} else {
this.serverErrorCount = 0;
const responseData = await response.text();
try {
const parsed = ical.parseICS(responseData);
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
this.events = CalendarFetcherUtils.filterEvents(parsed, {
excludedEvents: this.excludedEvents,
includePastEvents: this.includePastEvents,
maximumEntries: this.maximumEntries,
maximumNumberOfDays: this.maximumNumberOfDays
});
this.lastFetch = Date.now();
this.broadcastEvents();
} catch (error) {
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
this.fetchFailedCallback(this, error);
}
}
} catch (error) {
Log.error(`${this.url} - Fetch failed: ${error.message}`);
this.fetchFailedCallback(this, error);
}
this.scheduleNextFetch(nextDelay);
fetchCalendar () {
this.httpFetcher.startPeriodicFetch();
}
/**
@@ -196,7 +98,7 @@ class CalendarFetcher {
return true;
}
const timeSinceLastFetch = Date.now() - this.lastFetch;
return timeSinceLastFetch >= this.reloadInterval;
return timeSinceLastFetch >= this.httpFetcher.reloadInterval;
}
/**

View File

@@ -61,12 +61,11 @@ module.exports = NodeHelper.create({
this.broadcastEvents(fetcher, identifier);
});
fetcher.onError((fetcher, error) => {
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error);
let error_type = NodeHelper.checkFetchError(error);
fetcher.onError((fetcher, errorInfo) => {
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, errorInfo.message || errorInfo);
this.sendSocketNotification("CALENDAR_ERROR", {
id: identifier,
error_type
error_type: errorInfo.translationKey
});
});

532
package-lock.json generated
View File

@@ -51,6 +51,7 @@
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"markdownlint-cli2": "^0.20.0",
"msw": "^2.12.7",
"playwright": "^1.57.0",
"prettier": "^3.7.4",
"prettier-plugin-jinja-template": "^2.1.0",
@@ -1894,6 +1895,187 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
"integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@inquirer/confirm": {
"version": "5.1.21",
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz",
"integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inquirer/core": "^10.3.2",
"@inquirer/type": "^3.0.10"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/core": {
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz",
"integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^1.0.2",
"@inquirer/figures": "^1.0.15",
"@inquirer/type": "^3.0.10",
"cli-width": "^4.1.0",
"mute-stream": "^2.0.0",
"signal-exit": "^4.1.0",
"wrap-ansi": "^6.2.0",
"yoctocolors-cjs": "^2.1.3"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/core/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@inquirer/core/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@inquirer/core/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@inquirer/core/node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
"integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/@inquirer/core/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@inquirer/core/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@inquirer/core/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@inquirer/core/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@inquirer/figures": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
"integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@inquirer/type": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz",
"integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@@ -1964,6 +2146,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@mswjs/interceptors": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
"integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@open-draft/deferred-promise": "^2.2.0",
"@open-draft/logger": "^0.3.0",
"@open-draft/until": "^2.0.0",
"is-node-process": "^1.2.0",
"outvariant": "^1.4.3",
"strict-event-emitter": "^0.5.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2015,6 +2215,31 @@
"node": ">= 8"
}
},
"node_modules/@open-draft/deferred-promise": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
"dev": true,
"license": "MIT"
},
"node_modules/@open-draft/logger": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
"integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-node-process": "^1.2.0",
"outvariant": "^1.4.0"
}
},
"node_modules/@open-draft/until": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"dev": true,
"license": "MIT"
},
"node_modules/@pm2/agent": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@pm2/agent/-/agent-2.1.1.tgz",
@@ -2880,6 +3105,13 @@
"@types/node": "*"
}
},
"node_modules/@types/statuses": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
@@ -4410,6 +4642,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-width": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 12"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
@@ -7038,6 +7280,16 @@
"license": "ISC",
"optional": true
},
"node_modules/graphql": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
"integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
@@ -7159,6 +7411,13 @@
"node": ">= 0.4"
}
},
"node_modules/headers-polyfill": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
@@ -7762,6 +8021,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-node-process": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
"dev": true,
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -9319,6 +9585,205 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msw": {
"version": "2.12.7",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.12.7.tgz",
"integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@inquirer/confirm": "^5.0.0",
"@mswjs/interceptors": "^0.40.0",
"@open-draft/deferred-promise": "^2.2.0",
"@types/statuses": "^2.0.6",
"cookie": "^1.0.2",
"graphql": "^16.12.0",
"headers-polyfill": "^4.0.2",
"is-node-process": "^1.2.0",
"outvariant": "^1.4.3",
"path-to-regexp": "^6.3.0",
"picocolors": "^1.1.1",
"rettime": "^0.7.0",
"statuses": "^2.0.2",
"strict-event-emitter": "^0.5.1",
"tough-cookie": "^6.0.0",
"type-fest": "^5.2.0",
"until-async": "^3.0.2",
"yargs": "^17.7.2"
},
"bin": {
"msw": "cli/index.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mswjs"
},
"peerDependencies": {
"typescript": ">= 4.8.x"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/msw/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/msw/node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/msw/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/msw/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/msw/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/msw/node_modules/path-to-regexp": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"dev": true,
"license": "MIT"
},
"node_modules/msw/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/msw/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/msw/node_modules/type-fest": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.1.tgz",
"integrity": "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/msw/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/msw/node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/msw/node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
@@ -9632,6 +10097,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/outvariant": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
"dev": true,
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -10619,6 +11091,16 @@
"node": ">= 4.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -10745,6 +11227,13 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rettime": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz",
"integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==",
"dev": true,
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -11610,6 +12099,13 @@
"node": ">= 0.4"
}
},
"node_modules/strict-event-emitter": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -12201,6 +12697,19 @@
"node": ">=8"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -12621,6 +13130,16 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
"node_modules/until-async": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
"integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/kettanaito"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -13309,6 +13828,19 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoctocolors-cjs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
"integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -32,6 +32,9 @@
},
"#server_functions": {
"default": "./js/server_functions.js"
},
"#http_fetcher": {
"default": "./js/http_fetcher.js"
}
},
"main": "js/electron.js",
@@ -116,6 +119,7 @@
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"markdownlint-cli2": "^0.20.0",
"msw": "^2.12.7",
"playwright": "^1.57.0",
"prettier": "^3.7.4",
"prettier-plugin-jinja-template": "^2.1.0",

View File

@@ -0,0 +1,443 @@
const { http, HttpResponse } = require("msw");
const { setupServer } = require("msw/node");
const HTTPFetcher = require("#http_fetcher");
const TEST_URL = "http://test.example.com/data";
let server;
let fetcher;
beforeAll(() => {
server = setupServer();
server.listen({ onUnhandledRequest: "error" });
});
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
if (fetcher) {
fetcher.clearTimer();
fetcher = null;
}
});
describe("HTTPFetcher", () => {
describe("Basic fetch operations", () => {
it("should emit response event on successful fetch", async () => {
const responseData = "test data";
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text(responseData);
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", (response) => {
resolve(response);
});
});
fetcher.startPeriodicFetch();
const response = await responsePromise;
expect(response.ok).toBe(true);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe(responseData);
});
it("should emit error event on network failure", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.error();
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo).toHaveProperty("errorType", "NETWORK_ERROR");
expect(errorInfo).toHaveProperty("translationKey", "MODULE_ERROR_NO_CONNECTION");
expect(errorInfo).toHaveProperty("url", TEST_URL);
});
it("should emit error event on timeout", async () => {
server.use(
http.get(TEST_URL, async () => {
// Simulate a slow server that never responds
await new Promise((resolve) => setTimeout(resolve, 60000));
return HttpResponse.text("too late");
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000, timeout: 100 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
expect(errorInfo.message).toContain("timeout");
expect(errorInfo.message).toContain("100ms");
});
});
describe("HTTPFetcher - HTTP status code handling", () => {
describe("401/403 errors (Auth failures)", () => {
it("should emit error with AUTH_FAILURE for 401", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 401 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(401);
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
expect(errorInfo.translationKey).toBe("MODULE_ERROR_UNAUTHORIZED");
});
it("should emit error with AUTH_FAILURE for 403", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 403 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(403);
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
});
});
describe("429 errors (Rate limiting)", () => {
it("should emit error with RATE_LIMITED for 429", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, {
status: 429,
headers: { "Retry-After": "120" }
});
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(429);
expect(errorInfo.errorType).toBe("RATE_LIMITED");
expect(errorInfo.retryAfter).toBeGreaterThan(0);
});
it("should parse Retry-After header in seconds", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, {
status: 429,
headers: { "Retry-After": "300" }
});
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
// 300 seconds = 300000 ms
expect(errorInfo.retryAfter).toBe(300000);
});
});
describe("5xx errors (Server errors)", () => {
it("should emit error with SERVER_ERROR for 500", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 500 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(500);
expect(errorInfo.errorType).toBe("SERVER_ERROR");
});
it("should emit error with SERVER_ERROR for 503", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 503 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(503);
expect(errorInfo.errorType).toBe("SERVER_ERROR");
});
});
describe("4xx errors (Client errors)", () => {
it("should emit error with CLIENT_ERROR for 404", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 404 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(404);
expect(errorInfo.errorType).toBe("CLIENT_ERROR");
});
});
});
});
describe("HTTPFetcher - Authentication", () => {
it("should include Basic auth header when configured", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
auth: {
method: "basic",
user: "testuser",
pass: "testpass"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
const expectedAuth = `Basic ${Buffer.from("testuser:testpass").toString("base64")}`;
expect(receivedHeaders.authorization).toBe(expectedAuth);
});
it("should include Bearer auth header when configured", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
auth: {
method: "bearer",
pass: "my-token-123"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
expect(receivedHeaders.authorization).toBe("Bearer my-token-123");
});
});
describe("Custom headers", () => {
it("should include custom headers in request", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
headers: {
"X-Custom-Header": "custom-value",
Accept: "application/json"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
expect(receivedHeaders["x-custom-header"]).toBe("custom-value");
expect(receivedHeaders.accept).toBe("application/json");
});
});
describe("Timer management", () => {
it("should not set timer in test mode", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
// Timer should NOT be set in test mode (mmTestMode=true)
expect(fetcher.reloadTimer).toBeNull();
});
it("should clear timer when clearTimer is called", () => {
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
// Manually set a timer to test clearing
fetcher.reloadTimer = setTimeout(() => {}, 10000);
expect(fetcher.reloadTimer).not.toBeNull();
fetcher.clearTimer();
expect(fetcher.reloadTimer).toBeNull();
});
});
describe("fetch() method", () => {
it("should emit response event when called", async () => {
const responseData = "direct fetch data";
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text(responseData);
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
await fetcher.fetch();
const response = await responsePromise;
expect(response.ok).toBe(true);
const text = await response.text();
expect(text).toBe(responseData);
});
it("should emit error event on network error", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.error();
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", resolve);
});
await fetcher.fetch();
const errorInfo = await errorPromise;
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
});
});

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Ongeldige URL.",
"MODULE_ERROR_NO_CONNECTION": "Geen internetverbinding.",
"MODULE_ERROR_UNAUTHORIZED": "Owerheid het misluk.",
"MODULE_ERROR_RATE_LIMITED": "Te veel versoeke. Probeer later weer.",
"MODULE_ERROR_SERVER_ERROR": "Bediener fout. Probeer later weer.",
"MODULE_ERROR_CLIENT_ERROR": "Versoek het misluk.",
"MODULE_ERROR_UNSPECIFIED": "Gaan die logs na vir meer besonderhede.",
"NEWSFEED_NO_ITEMS": "Geen nuus op die oomblik.",

View File

@@ -34,6 +34,9 @@
"MODULE_ERROR_MALFORMED_URL": "رابط غير صحيح.",
"MODULE_ERROR_NO_CONNECTION": "لا يوجد اتصال بالإنترنت.",
"MODULE_ERROR_UNAUTHORIZED": "فشل التصريح.",
"MODULE_ERROR_RATE_LIMITED": "طلبات كثيرة جدا. إعادة المحاولة لاحقا.",
"MODULE_ERROR_SERVER_ERROR": "خطأ في الخادم. إعادة المحاولة لاحقا.",
"MODULE_ERROR_CLIENT_ERROR": "فشل الطلب.",
"MODULE_ERROR_UNSPECIFIED": "تحقق من السجلات لمزيد من التفاصيل.",
"NEWSFEED_NO_ITEMS": "لا توجد أخبار في الوقت الحالي.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Неправилен URL адрес.",
"MODULE_ERROR_NO_CONNECTION": "Няма интернет връзка.",
"MODULE_ERROR_UNAUTHORIZED": "Неуспешна авторизация.",
"MODULE_ERROR_RATE_LIMITED": "Твърде много заявки. Повторен опит по-късно.",
"MODULE_ERROR_SERVER_ERROR": "Грешка в сървъра. Повторен опит по-късно.",
"MODULE_ERROR_CLIENT_ERROR": "Заявката неуспешна.",
"MODULE_ERROR_UNSPECIFIED": "Проверете логовете за повече подробности.",
"NEWSFEED_NO_ITEMS": "Няма новини в момента.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "L'URL és mal format.",
"MODULE_ERROR_NO_CONNECTION": "No hi ha connexió a Internet.",
"MODULE_ERROR_UNAUTHORIZED": "L'autorització ha fallat.",
"MODULE_ERROR_RATE_LIMITED": "Masses sol·licituds. Reintentant més tard.",
"MODULE_ERROR_SERVER_ERROR": "Error del servidor. Reintentant més tard.",
"MODULE_ERROR_CLIENT_ERROR": "La sol·licitud ha fallat.",
"MODULE_ERROR_UNSPECIFIED": "Consulta els registres per a més detalls.",
"NEWSFEED_NO_ITEMS": "No hi ha notícies disponibles en aquest moment.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Nesprávná URL adresa.",
"MODULE_ERROR_NO_CONNECTION": "Není připojení k internetu.",
"MODULE_ERROR_UNAUTHORIZED": "Autorizace selhala.",
"MODULE_ERROR_RATE_LIMITED": "Příliš mnoho požadavků. Zkouším znovu později.",
"MODULE_ERROR_SERVER_ERROR": "Chyba serveru. Zkouším znovu později.",
"MODULE_ERROR_CLIENT_ERROR": "Požadavek selhal.",
"MODULE_ERROR_UNSPECIFIED": "Zkontrolujte protokoly pro více informací.",
"NEWSFEED_NO_ITEMS": "Žádné zprávy.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "Ҫӗҫ ҫӗнӗ URL хата.",
"MODULE_ERROR_NO_CONNECTION": "Интернет-пулла хӗҫҫӗн.",
"MODULE_ERROR_UNAUTHORIZED": "Авторизация хата.",
"MODULE_ERROR_RATE_LIMITED": "Нумай ыйту. Хыҫра тепӗр хут.",
"MODULE_ERROR_SERVER_ERROR": "Сервер хатӗ. Хыҫра тепӗр хут.",
"MODULE_ERROR_CLIENT_ERROR": "Ыйту хатӗ.",
"MODULE_ERROR_UNSPECIFIED": "Тӗп лог ҫӗнтерӗ.",
"NEWSFEED_NO_ITEMS": "Пулас ҫӗнтер ҫук.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL anghywir.",
"MODULE_ERROR_NO_CONNECTION": "Dim cysylltiad rhyngrwyd.",
"MODULE_ERROR_UNAUTHORIZED": "Methiant awdurdodi.",
"MODULE_ERROR_RATE_LIMITED": "Gormod o geisiadau. Yn ceisio eto yn nes ymlaen.",
"MODULE_ERROR_SERVER_ERROR": "Gwall gweinydd. Yn ceisio eto yn nes ymlaen.",
"MODULE_ERROR_CLIENT_ERROR": "Cais wedi methu.",
"MODULE_ERROR_UNSPECIFIED": "Gwiriwch y logiau am ragor o fanylion.",
"NEWSFEED_NO_ITEMS": "Dim newyddion ar hyn o bryd.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Forkert url.",
"MODULE_ERROR_NO_CONNECTION": "Ingen internetforbindelse.",
"MODULE_ERROR_UNAUTHORIZED": "Godkendelse mislykkedes.",
"MODULE_ERROR_RATE_LIMITED": "For mange anmodninger. Prøver igen senere.",
"MODULE_ERROR_SERVER_ERROR": "Serverfejl. Prøver igen senere.",
"MODULE_ERROR_CLIENT_ERROR": "Anmodning mislykkedes.",
"MODULE_ERROR_UNSPECIFIED": "Tjek logfiler for flere detaljer.",
"NEWSFEED_NO_ITEMS": "Ingen nyheder i øjeblikket.",

View File

@@ -37,6 +37,9 @@
"MODULE_ERROR_MALFORMED_URL": "Fehlerhafte URL.",
"MODULE_ERROR_NO_CONNECTION": "Keine Internetverbindung.",
"MODULE_ERROR_UNAUTHORIZED": "Autorisierung fehlgeschlagen.",
"MODULE_ERROR_RATE_LIMITED": "Zu viele Anfragen. Erneuter Versuch später.",
"MODULE_ERROR_SERVER_ERROR": "Serverfehler. Erneuter Versuch später.",
"MODULE_ERROR_CLIENT_ERROR": "Anfrage fehlgeschlagen.",
"MODULE_ERROR_UNSPECIFIED": "Prüfe die Logdateien für weitere Details.",
"NEWSFEED_NO_ITEMS": "Momentan keine Neuigkeiten.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Λανθασμένη μορφή url.",
"MODULE_ERROR_NO_CONNECTION": "Δεν υπάρχει σύνδεση στο διαδίκτυο.",
"MODULE_ERROR_UNAUTHORIZED": "Η εξουσιοδότηση απέτυχε.",
"MODULE_ERROR_RATE_LIMITED": "Πάρα πολλά αιτήματα. Θα ξαναπροσπαθήσω αργότερα.",
"MODULE_ERROR_SERVER_ERROR": "Σφάλμα διακομιστή. Θα ξαναπροσπαθήσω αργότερα.",
"MODULE_ERROR_CLIENT_ERROR": "Το αίτημα απέτυχε.",
"MODULE_ERROR_UNSPECIFIED": "Ελέγξτε τα αρχεία καταγραφής για περισσότερες λεπτομέρειες.",
"NEWSFEED_NO_ITEMS": "Δεν υπάρχουν ειδήσεις αυτή τη στιγμή.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "Malformed url.",
"MODULE_ERROR_NO_CONNECTION": "No internet connection.",
"MODULE_ERROR_UNAUTHORIZED": "Authorization failed.",
"MODULE_ERROR_RATE_LIMITED": "Too many requests. Retrying later.",
"MODULE_ERROR_SERVER_ERROR": "Server error. Retrying later.",
"MODULE_ERROR_CLIENT_ERROR": "Request failed.",
"MODULE_ERROR_UNSPECIFIED": "Check logs for more details.",
"NEWSFEED_NO_ITEMS": "No news at the moment.",

View File

@@ -37,6 +37,9 @@
"MODULE_ERROR_MALFORMED_URL": "Malĝusta URL.",
"MODULE_ERROR_NO_CONNECTION": "Neniu interreta konekto.",
"MODULE_ERROR_UNAUTHORIZED": "Aŭtorigo malsukcesis.",
"MODULE_ERROR_RATE_LIMITED": "Tro multaj petoj. Reprovo poste.",
"MODULE_ERROR_SERVER_ERROR": "Servila eraro. Reprovo poste.",
"MODULE_ERROR_CLIENT_ERROR": "Peto malsukcesis.",
"MODULE_ERROR_UNSPECIFIED": "Kontrolu la protokolajn dosierojn por pli da detaloj.",
"NEWSFEED_NO_ITEMS": "Momente neniu novaĵoj.",

View File

@@ -37,6 +37,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL mal formado.",
"MODULE_ERROR_NO_CONNECTION": "No hay conexión a Internet.",
"MODULE_ERROR_UNAUTHORIZED": "No autorizado.",
"MODULE_ERROR_RATE_LIMITED": "Demasiadas solicitudes. Reintentando más tarde.",
"MODULE_ERROR_SERVER_ERROR": "Error del servidor. Reintentando más tarde.",
"MODULE_ERROR_CLIENT_ERROR": "La solicitud falló.",
"MODULE_ERROR_UNSPECIFIED": "Por favor, consulte los registros para obtener más información.",
"NEWSFEED_NO_ITEMS": "No hay noticias disponibles en este momento.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Ebakorrektne url.",
"MODULE_ERROR_NO_CONNECTION": "Interneti ühendus puudub.",
"MODULE_ERROR_UNAUTHORIZED": "Autoriseerimine ebaõnnestus.",
"MODULE_ERROR_RATE_LIMITED": "Liiga palju päringuid. Proovin hiljem uuesti.",
"MODULE_ERROR_SERVER_ERROR": "Serveri viga. Proovin hiljem uuesti.",
"MODULE_ERROR_CLIENT_ERROR": "Päring ebaõnnestus.",
"MODULE_ERROR_UNSPECIFIED": "Lisateabe saamiseks kontrollige logifaile.",
"NEWSFEED_NO_ITEMS": "Hetkel ei ole uudiseid.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Virheellinen url.",
"MODULE_ERROR_NO_CONNECTION": "Ei internet-yhteyttä.",
"MODULE_ERROR_UNAUTHORIZED": "Valtuutus epäonnistui.",
"MODULE_ERROR_RATE_LIMITED": "Liikaa pyyntöjä. Yritetään myöhemmin uudelleen.",
"MODULE_ERROR_SERVER_ERROR": "Palvelinvirhe. Yritetään myöhemmin uudelleen.",
"MODULE_ERROR_CLIENT_ERROR": "Pyyntö epäonnistui.",
"MODULE_ERROR_UNSPECIFIED": "Tarkista lokitiedostot saadaksesi lisätietoja.",
"NEWSFEED_NO_ITEMS": "Ei uutisia tällä hetkellä.",

View File

@@ -37,6 +37,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL mal formée.",
"MODULE_ERROR_NO_CONNECTION": "Pas de connexion Internet.",
"MODULE_ERROR_UNAUTHORIZED": "L'autorisation à échouée.",
"MODULE_ERROR_RATE_LIMITED": "Trop de requêtes. Nouvelle tentative plus tard.",
"MODULE_ERROR_SERVER_ERROR": "Erreur du serveur. Nouvelle tentative plus tard.",
"MODULE_ERROR_CLIENT_ERROR": "La requête a échoué.",
"MODULE_ERROR_UNSPECIFIED": "Consultez les journaux pour plus de détails.",
"NEWSFEED_NO_ITEMS": "Aucune nouvelle pour le moment.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "De URL is net jildich.",
"MODULE_ERROR_NO_CONNECTION": "Gjin ynternetferbining.",
"MODULE_ERROR_UNAUTHORIZED": "Autorisearje mislearre.",
"MODULE_ERROR_RATE_LIMITED": "Te folle fersiken. Letter opnij besykje.",
"MODULE_ERROR_SERVER_ERROR": "Tsjinnerfout. Letter opnij besykje.",
"MODULE_ERROR_CLIENT_ERROR": "Fersyk mislearre.",
"MODULE_ERROR_UNSPECIFIED": "Sjoch de logs foar mear ynformaasje.",
"NEWSFEED_NO_ITEMS": "Op it stuit gjin nijsberjochten.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL mal formado.",
"MODULE_ERROR_NO_CONNECTION": "Non hai conexión a Internet.",
"MODULE_ERROR_UNAUTHORIZED": "A autorización fallou.",
"MODULE_ERROR_RATE_LIMITED": "Demasiadas solicitudes. Reintentando máis tarde.",
"MODULE_ERROR_SERVER_ERROR": "Erro do servidor. Reintentando máis tarde.",
"MODULE_ERROR_CLIENT_ERROR": "A solicitude fallou.",
"MODULE_ERROR_UNSPECIFIED": "Verifique os rexistros para obter máis información.",
"NEWSFEED_NO_ITEMS": "Non hai novas no momento.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "ખોટી URL.",
"MODULE_ERROR_NO_CONNECTION": "ઇન્ટરનેટ કનેક્શન નથી.",
"MODULE_ERROR_UNAUTHORIZED": "અધિકૃત કરવું નિષ્ફળ.",
"MODULE_ERROR_RATE_LIMITED": "ઘણી બધી વિનંતીઓ. પછીથી પુનઃપ્રયાસ.",
"MODULE_ERROR_SERVER_ERROR": "સર્વર ભૂલ. પછીથી પુનઃપ્રયાસ.",
"MODULE_ERROR_CLIENT_ERROR": "વિનંતી નિષ્ફળ.",
"MODULE_ERROR_UNSPECIFIED": "વધુ વિગતો માટે લોગ તપાસો.",
"NEWSFEED_NO_ITEMS": "હાલમાં કોઈ સમાચાર નથી.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "כתובת אתר לא תקינה.",
"MODULE_ERROR_NO_CONNECTION": "אין חיבור לאינטרנט.",
"MODULE_ERROR_UNAUTHORIZED": "הזדהות נכשלה.",
"MODULE_ERROR_RATE_LIMITED": "יותר מדי בקשות. מנסה שוב מאוחר יותר.",
"MODULE_ERROR_SERVER_ERROR": "שגיאת שרת. מנסה שוב מאוחר יותר.",
"MODULE_ERROR_CLIENT_ERROR": "הבקשה נכשלה.",
"MODULE_ERROR_UNSPECIFIED": "בדוק את היומנים לפרטים נוספים.",
"NEWSFEED_NO_ITEMS": "אין חדשות כרגע.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "गलत URL।",
"MODULE_ERROR_NO_CONNECTION": "कोई इंटरनेट कनेक्शन नहीं।",
"MODULE_ERROR_UNAUTHORIZED": "प्राधिकरण विफल।",
"MODULE_ERROR_RATE_LIMITED": "बहुत सारे अनुरोध। बाद में पुनः प्रयास करना।",
"MODULE_ERROR_SERVER_ERROR": "सर्वर त्रुटि। बाद में पुनः प्रयास करना।",
"MODULE_ERROR_CLIENT_ERROR": "अनुरोध विफल रहा।",
"MODULE_ERROR_UNSPECIFIED": "अधिक जानकारी के लिए लॉग जांचें।",
"NEWSFEED_NO_ITEMS": "इस समय कोई समाचार नहीं।",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Neispravan URL.",
"MODULE_ERROR_NO_CONNECTION": "Nema internetske veze.",
"MODULE_ERROR_UNAUTHORIZED": "Autorizacija nije uspjela.",
"MODULE_ERROR_RATE_LIMITED": "Previše zahtjeva. Ponovni pokušaj kasnije.",
"MODULE_ERROR_SERVER_ERROR": "Greška poslužitelja. Ponovni pokušaj kasnije.",
"MODULE_ERROR_CLIENT_ERROR": "Zahtjev nije uspio.",
"MODULE_ERROR_UNSPECIFIED": "Provjerite dnevnike za više informacija.",
"NEWSFEED_NO_ITEMS": "Trenutno nema vijesti.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "Hibás URL.",
"MODULE_ERROR_NO_CONNECTION": "Nincs internetkapcsolat.",
"MODULE_ERROR_UNAUTHORIZED": "Azonosítás sikertelen.",
"MODULE_ERROR_RATE_LIMITED": "Túl sok kérés. Újrapróbálkozás később.",
"MODULE_ERROR_SERVER_ERROR": "Szerverhiba. Újrapróbálkozás később.",
"MODULE_ERROR_CLIENT_ERROR": "A kérés meghiúsult.",
"MODULE_ERROR_UNSPECIFIED": "Ellenőrizze a naplókat további részletekért.",
"NEWSFEED_NO_ITEMS": "Jelenleg nincsenek hírek.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL tidak valid.",
"MODULE_ERROR_NO_CONNECTION": "Tidak ada koneksi internet.",
"MODULE_ERROR_UNAUTHORIZED": "Gagal otentikasi.",
"MODULE_ERROR_RATE_LIMITED": "Terlalu banyak permintaan. Mencoba lagi nanti.",
"MODULE_ERROR_SERVER_ERROR": "Kesalahan server. Mencoba lagi nanti.",
"MODULE_ERROR_CLIENT_ERROR": "Permintaan gagal.",
"MODULE_ERROR_UNSPECIFIED": "Silakan periksa log untuk informasi lebih lanjut.",
"NEWSFEED_NO_ITEMS": "Saat ini tidak ada berita.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Villa í slóð.",
"MODULE_ERROR_NO_CONNECTION": "Engin nettenging.",
"MODULE_ERROR_UNAUTHORIZED": "Auðkenning mistókst.",
"MODULE_ERROR_RATE_LIMITED": "Of margar beiðnir. Reyni aftur síðar.",
"MODULE_ERROR_SERVER_ERROR": "Villa í þjóni. Reyni aftur síðar.",
"MODULE_ERROR_CLIENT_ERROR": "Beiðni mistókst.",
"MODULE_ERROR_UNSPECIFIED": "Vinsamlegast athugaðu skráningu fyrir frekari upplýsingar.",
"NEWSFEED_NO_ITEMS": "Engar fréttir í boði núna.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL non valido.",
"MODULE_ERROR_NO_CONNECTION": "Nessuna connessione a Internet.",
"MODULE_ERROR_UNAUTHORIZED": "Autenticazione non riuscita.",
"MODULE_ERROR_RATE_LIMITED": "Troppe richieste. Riprovo più tardi.",
"MODULE_ERROR_SERVER_ERROR": "Errore del server. Riprovo più tardi.",
"MODULE_ERROR_CLIENT_ERROR": "Richiesta fallita.",
"MODULE_ERROR_UNSPECIFIED": "Si prega di controllare i log per ulteriori dettagli.",
"NEWSFEED_NO_ITEMS": "Al momento non ci sono notizie.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "不正なURLです。",
"MODULE_ERROR_NO_CONNECTION": "インターネット接続がありません。",
"MODULE_ERROR_UNAUTHORIZED": "認証に失敗しました。",
"MODULE_ERROR_RATE_LIMITED": "リクエストが多すぎます。後で再試行します。",
"MODULE_ERROR_SERVER_ERROR": "サーバーエラー。後で再試行します。",
"MODULE_ERROR_CLIENT_ERROR": "リクエストが失敗しました。",
"MODULE_ERROR_UNSPECIFIED": "詳細はログを確認してください。",
"NEWSFEED_NO_ITEMS": "現在ニュースはありません。",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "잘못된 URL 형식입니다.",
"MODULE_ERROR_NO_CONNECTION": "인터넷이 연결되지 않았습니다.",
"MODULE_ERROR_UNAUTHORIZED": "인증이 실패했습니다.",
"MODULE_ERROR_RATE_LIMITED": "요청이 너무 많습니다. 나중에 다시 시도합니다.",
"MODULE_ERROR_SERVER_ERROR": "서버 오류. 나중에 다시 시도합니다.",
"MODULE_ERROR_CLIENT_ERROR": "요청 실패.",
"MODULE_ERROR_UNSPECIFIED": "상세 내용은 로그를 확인하세요.",
"NEWSFEED_NO_ITEMS": "현재 뉴스가 없습니다.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Netinkama URL nuoroda.",
"MODULE_ERROR_NO_CONNECTION": "Nėra interneto ryšio.",
"MODULE_ERROR_UNAUTHORIZED": "Autorizacija nepavyko.",
"MODULE_ERROR_RATE_LIMITED": "Per daug užklausų. Bandoma vėl vėliau.",
"MODULE_ERROR_SERVER_ERROR": "Serverio klaida. Bandoma vėl vėliau.",
"MODULE_ERROR_CLIENT_ERROR": "Užklausa nepavyko.",
"MODULE_ERROR_UNSPECIFIED": "Patikrinkite žurnalus, kad gautumėte daugiau informacijos.",
"NEWSFEED_NO_ITEMS": "Šiuo metu naujienų nėra.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL tidak sah.",
"MODULE_ERROR_NO_CONNECTION": "Tiada sambungan internet.",
"MODULE_ERROR_UNAUTHORIZED": "Kebenaran gagal.",
"MODULE_ERROR_RATE_LIMITED": "Terlalu banyak permintaan. Cuba lagi nanti.",
"MODULE_ERROR_SERVER_ERROR": "Ralat pelayan. Cuba lagi nanti.",
"MODULE_ERROR_CLIENT_ERROR": "Permintaan gagal.",
"MODULE_ERROR_UNSPECIFIED": "Sila semak log untuk maklumat lanjut.",
"NEWSFEED_NO_ITEMS": "Tiada berita buat masa ini.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Ugyldig URL.",
"MODULE_ERROR_NO_CONNECTION": "Ingen internettforbindelse.",
"MODULE_ERROR_UNAUTHORIZED": "Autentisering mislyktes.",
"MODULE_ERROR_RATE_LIMITED": "For mange forespørsler. Prøver igjen senere.",
"MODULE_ERROR_SERVER_ERROR": "Serverfeil. Prøver igjen senere.",
"MODULE_ERROR_CLIENT_ERROR": "Forespørselen mislyktes.",
"MODULE_ERROR_UNSPECIFIED": "Vennligst sjekk loggene for mer informasjon.",
"NEWSFEED_NO_ITEMS": "Ingen nyheter tilgjengelig for øyeblikket.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Ongeldige url.",
"MODULE_ERROR_NO_CONNECTION": "Geen internet verbinding.",
"MODULE_ERROR_UNAUTHORIZED": "Authenticatie mislukt.",
"MODULE_ERROR_RATE_LIMITED": "Te veel verzoeken. Later opnieuw proberen.",
"MODULE_ERROR_SERVER_ERROR": "Serverfout. Later opnieuw proberen.",
"MODULE_ERROR_CLIENT_ERROR": "Verzoek mislukt.",
"MODULE_ERROR_UNSPECIFIED": "Bekijk de logs voor meer informatie.",
"NEWSFEED_NO_ITEMS": "Geen nieuws op dit moment.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Ugyldig URL.",
"MODULE_ERROR_NO_CONNECTION": "Ingen internettforbindelse.",
"MODULE_ERROR_UNAUTHORIZED": "Autentisering mislyktes.",
"MODULE_ERROR_RATE_LIMITED": "For mange førespurnader. Prøvar igjen seinare.",
"MODULE_ERROR_SERVER_ERROR": "Serverfeil. Prøvar igjen seinare.",
"MODULE_ERROR_CLIENT_ERROR": "Førespurnaden feila.",
"MODULE_ERROR_UNSPECIFIED": "Vennligst sjekk loggfilene for meir informasjon.",
"NEWSFEED_NO_ITEMS": "Ingen nyhende tilgjengeleg no.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Nieprawidłowy adres URL.",
"MODULE_ERROR_NO_CONNECTION": "Brak połączenia z internetem.",
"MODULE_ERROR_UNAUTHORIZED": "Autoryzacja nie powiodła się.",
"MODULE_ERROR_RATE_LIMITED": "Zbyt wiele żądań. Ponowna próba później.",
"MODULE_ERROR_SERVER_ERROR": "Błąd serwera. Ponowna próba później.",
"MODULE_ERROR_CLIENT_ERROR": "Żądanie nie powiodło się.",
"MODULE_ERROR_UNSPECIFIED": "Sprawdź logi, aby uzyskać więcej informacji.",
"NEWSFEED_NO_ITEMS": "Brak wiadomości w tej chwili.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL inválido.",
"MODULE_ERROR_NO_CONNECTION": "Sem conexão com a Internet.",
"MODULE_ERROR_UNAUTHORIZED": "Falha na autenticação.",
"MODULE_ERROR_RATE_LIMITED": "Muitas requisições. Tentando novamente mais tarde.",
"MODULE_ERROR_SERVER_ERROR": "Erro do servidor. Tentando novamente mais tarde.",
"MODULE_ERROR_CLIENT_ERROR": "Requisição falhou.",
"MODULE_ERROR_UNSPECIFIED": "Verifique os logs para mais detalhes.",
"NEWSFEED_NO_ITEMS": "Atualmente não há notícias.",

View File

@@ -37,20 +37,13 @@
"MODULE_ERROR_MALFORMED_URL": "URL inválido.",
"MODULE_ERROR_NO_CONNECTION": "Sem ligação à internet.",
"MODULE_ERROR_UNAUTHORIZED": "Falha na autorização.",
"MODULE_ERROR_RATE_LIMITED": "Demasiados pedidos. A tentar novamente mais tarde.",
"MODULE_ERROR_SERVER_ERROR": "Erro do servidor. A tentar novamente mais tarde.",
"MODULE_ERROR_CLIENT_ERROR": "Pedido falhou.",
"MODULE_ERROR_UNSPECIFIED": "Consulta os registos para mais detalhes.",
"NEWSFEED_NO_ITEMS": "Sem notícias de momento.",
"UPDATE_NOTIFICATION": "Está disponível uma atualização do MagicMirror².",
"UPDATE_NOTIFICATION_MODULE": "Atualização disponível para o módulo {MODULE_NAME}.",
"UPDATE_INFO_SINGLE": "A instalação atual está {COMMIT_COUNT} commit atrás na ramificação {BRANCH_NAME}.",
"UPDATE_INFO_MULTIPLE": "A instalação atual está {COMMIT_COUNT} commits atrás na ramificação {BRANCH_NAME}.",
"UPDATE_NOTIFICATION_DONE": "Atualização concluída do módulo {MODULE_NAME}.",
"UPDATE_NOTIFICATION_ERROR": "Erro na atualização do módulo {MODULE_NAME}.",
"UPDATE_NOTIFICATION_NEED-RESTART": "É necessário reiniciar o MagicMirror.",
"MODULE_ERROR_MALFORMED_URL": "URL Inválido.",
"UPDATE_NOTIFICATION": "Está disponível uma atualização do MagicMirror².",
"UPDATE_NOTIFICATION_MODULE": "Atualização disponível para o módulo {MODULE_NAME}.",
"UPDATE_INFO_SINGLE": "A instalação atual está {COMMIT_COUNT} commit atrás na ramificação {BRANCH_NAME}.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL incorect.",
"MODULE_ERROR_NO_CONNECTION": "Fără conexiune la internet.",
"MODULE_ERROR_UNAUTHORIZED": "Autorizarea a eșuat.",
"MODULE_ERROR_RATE_LIMITED": "Prea multe cereri. Se reîncearcă mai târziu.",
"MODULE_ERROR_SERVER_ERROR": "Eroare de server. Se reîncearcă mai târziu.",
"MODULE_ERROR_CLIENT_ERROR": "Cererea a eșuat.",
"MODULE_ERROR_UNSPECIFIED": "Verificați jurnalele pentru mai multe detalii.",
"NEWSFEED_NO_ITEMS": "Nu există știri în acest moment.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Неверный URL.",
"MODULE_ERROR_NO_CONNECTION": "Нет интернет-соединения.",
"MODULE_ERROR_UNAUTHORIZED": "Не удалось авторизоваться.",
"MODULE_ERROR_RATE_LIMITED": "Слишком много запросов. Повторная попытка позже.",
"MODULE_ERROR_SERVER_ERROR": "Ошибка сервера. Повторная попытка позже.",
"MODULE_ERROR_CLIENT_ERROR": "Запрос не удался.",
"MODULE_ERROR_UNSPECIFIED": "Пожалуйста, проверьте логи для получения дополнительной информации.",
"NEWSFEED_NO_ITEMS": "В данный момент нет новостей.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Nesprávna URL adresa.",
"MODULE_ERROR_NO_CONNECTION": "Nie je pripojenie k internetu.",
"MODULE_ERROR_UNAUTHORIZED": "Autorizácia zlyhala.",
"MODULE_ERROR_RATE_LIMITED": "Príliš veľa požiadaviek. Skúšam znovu neskôr.",
"MODULE_ERROR_SERVER_ERROR": "Chyba servera. Skúšam znovu neskôr.",
"MODULE_ERROR_CLIENT_ERROR": "Požiadavka zlyhala.",
"MODULE_ERROR_UNSPECIFIED": "Skontrolujte protokoly pre viac informácií.",
"NEWSFEED_NO_ITEMS": "Momentálne žiadne správy.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Felaktig URL.",
"MODULE_ERROR_NO_CONNECTION": "Ingen internetanslutning.",
"MODULE_ERROR_UNAUTHORIZED": "Autentisering misslyckades.",
"MODULE_ERROR_RATE_LIMITED": "För många förfrågningar. Försöker igen senare.",
"MODULE_ERROR_SERVER_ERROR": "Serverfel. Försöker igen senare.",
"MODULE_ERROR_CLIENT_ERROR": "Förfrågan misslyckades.",
"MODULE_ERROR_UNSPECIFIED": "Vänligen kontrollera loggarna för mer information.",
"NEWSFEED_NO_ITEMS": "Inga nyheter för tillfället.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL ผิดรูปแบบ",
"MODULE_ERROR_NO_CONNECTION": "ไม่มีการเชื่อมต่ออินเทอร์เน็ต.",
"MODULE_ERROR_UNAUTHORIZED": "การอนุญาตล้มเหลว",
"MODULE_ERROR_RATE_LIMITED": "คำขอมากเกินไป กำลังลองใหม่ในภายหลัง",
"MODULE_ERROR_SERVER_ERROR": "ข้อผิดพลาดเซิร์ฟเวอร์ กำลังลองใหม่ในภายหลัง",
"MODULE_ERROR_CLIENT_ERROR": "คำขอล้มเหลว",
"MODULE_ERROR_UNSPECIFIED": "ตรวจสอบบันทึกสำหรับรายละเอียดเพิ่มเติม",
"NEWSFEED_NO_ITEMS": "ไม่มีข่าวในขณะนี้",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL ghobe' yImev.",
"MODULE_ERROR_NO_CONNECTION": "Internet ghobe' yImev.",
"MODULE_ERROR_UNAUTHORIZED": "ghobe' yImev.",
"MODULE_ERROR_RATE_LIMITED": "tlhoy rurqu' lut. vaj ratlh.",
"MODULE_ERROR_SERVER_ERROR": "Qagh server. vaj ratlh.",
"MODULE_ERROR_CLIENT_ERROR": "lut Qagh.",
"MODULE_ERROR_UNSPECIFIED": "logmeyDaq yImev.",
"NEWSFEED_NO_ITEMS": "DaHghachmey ghobe' yImev.",

View File

@@ -35,6 +35,9 @@
"MODULE_ERROR_MALFORMED_URL": "Hatalı URL.",
"MODULE_ERROR_NO_CONNECTION": "İnternet bağlantısı yok.",
"MODULE_ERROR_UNAUTHORIZED": "Yetkilendirme başarısız.",
"MODULE_ERROR_RATE_LIMITED": "Çok fazla istek. Daha sonra yeniden deneniyor.",
"MODULE_ERROR_SERVER_ERROR": "Sunucu hatası. Daha sonra yeniden deneniyor.",
"MODULE_ERROR_CLIENT_ERROR": "İstek başarısız oldu.",
"MODULE_ERROR_UNSPECIFIED": "Daha fazla ayrıntı için günlükleri kontrol edin.",
"NEWSFEED_NO_ITEMS": "Şu anda haber yok.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "Неправильний URL.",
"MODULE_ERROR_NO_CONNECTION": "Немає підключення до Інтернету.",
"MODULE_ERROR_UNAUTHORIZED": "Авторизація не вдалася.",
"MODULE_ERROR_RATE_LIMITED": "Забагато запитів. Повторна спроба пізніше.",
"MODULE_ERROR_SERVER_ERROR": "Помилка сервера. Повторна спроба пізніше.",
"MODULE_ERROR_CLIENT_ERROR": "Запит не вдався.",
"MODULE_ERROR_UNSPECIFIED": "Перевірте журнали для отримання додаткової інформації.",
"NEWSFEED_NO_ITEMS": "Немає новин на даний момент.",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "URL格式错误。",
"MODULE_ERROR_NO_CONNECTION": "无网络连接。",
"MODULE_ERROR_UNAUTHORIZED": "授权失败。",
"MODULE_ERROR_RATE_LIMITED": "请求过多。稍后重试。",
"MODULE_ERROR_SERVER_ERROR": "服务器错误。稍后重试。",
"MODULE_ERROR_CLIENT_ERROR": "请求失败。",
"MODULE_ERROR_UNSPECIFIED": "请查看日志以获取更多详细信息。",
"NEWSFEED_NO_ITEMS": "目前没有新闻。",

View File

@@ -36,6 +36,9 @@
"MODULE_ERROR_MALFORMED_URL": "網址格式錯誤。",
"MODULE_ERROR_NO_CONNECTION": "無網路連線。",
"MODULE_ERROR_UNAUTHORIZED": "授權失敗。",
"MODULE_ERROR_RATE_LIMITED": "請求過多。稍後重試。",
"MODULE_ERROR_SERVER_ERROR": "伺服器錯誤。稍後重試。",
"MODULE_ERROR_CLIENT_ERROR": "請求失敗。",
"MODULE_ERROR_UNSPECIFIED": "查看日誌以了解詳情。",
"NEWSFEED_NO_ITEMS": "目前沒有新聞。",